From 29b2c9ddef5f3b1adb8754d930b62210b99581cd Mon Sep 17 00:00:00 2001 From: Chris Adams Date: Sun, 13 Oct 2024 13:26:57 +0200 Subject: [PATCH] Add the changes made by the new developers Based on making a diff against commit fe0f838609f878f21ac5a7b45340c38704ca4645, linked below https://github.com/agentGav/cl8/commit/fe0f838609f878f21ac5a7b45340c38704ca4645 --- .gitignore | 215 +- cl8/admin.py | 609 ++--- cl8/static/css/template.css | 70 + cl8/static/images/magnifying-glass.png | Bin 0 -> 400 bytes cl8/static/js/template.js | 19 + cl8/templates/_active_tags_list.html | 39 +- cl8/templates/_nav_menu.html | 216 +- cl8/templates/_profile.html | 288 ++- cl8/templates/_profile_create.html | 365 +-- cl8/templates/_profile_edit.html | 393 ++-- cl8/templates/_profile_empty.html | 31 +- cl8/templates/account/_nav_logged_out.html | 60 +- cl8/templates/account/base.html | 20 +- .../email/account_already_exists_message.html | 13 + .../email_confirmation_signup_message.html | 23 + cl8/templates/account/login.html | 161 +- cl8/templates/account/logout.html | 46 +- cl8/templates/account/password_reset.html | 82 +- .../account/password_reset_done.html | 47 +- .../account/snippets/already_logged_in.html | 11 + cl8/templates/invite_new_profile.mjml.html | 53 +- cl8/templates/pages/_paginated_profiles.html | 166 +- cl8/templates/pages/create_profile.html | 22 +- cl8/templates/pages/edit_profile.html | 22 +- cl8/templates/pages/home.html | 343 +-- cl8/users/admin.py | 284 +-- cl8/users/api/views.py | 1168 +++++----- cl8/users/filters.py | 128 +- cl8/users/forms.py | 323 +-- cl8/users/middleware.py | 87 +- cl8/users/models.py | 912 +++++--- cl8/users/templatetags/custom_tags.py | 16 + config/settings/base.py | 770 +++---- config/settings/local.py | 248 +- justfile | 96 +- package-lock.json | 2010 +++++++++++++++++ package.json | 18 + theme/templates/base.html | 128 +- 38 files changed, 6237 insertions(+), 3265 deletions(-) create mode 100644 cl8/static/css/template.css create mode 100644 cl8/static/images/magnifying-glass.png create mode 100644 cl8/static/js/template.js create mode 100644 cl8/templates/account/email/account_already_exists_message.html create mode 100644 cl8/templates/account/email/email_confirmation_signup_message.html create mode 100644 cl8/templates/account/snippets/already_logged_in.html create mode 100644 cl8/users/templatetags/custom_tags.py create mode 100644 package-lock.json create mode 100644 package.json diff --git a/.gitignore b/.gitignore index 80459f5..51f63f5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,102 +1,113 @@ - -# Byte-compiled / optimized / DLL files -__pycache__/ -*.py[cod] -*$py.class - - -# C extensions -*.so - -# Distribution / packaging -.Python -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -*.egg-info/ -.installed.cfg -*.egg - -# Unit test / coverage reports -htmlcov/ -.tox/ -.coverage -.coverage.* -.cache -nosetests.xml -coverage.xml -*.cover -.hypothesis/ - -# Translations -*.mo -*.pot - -# Django stuff: -staticfiles/ - -# Virtual Environments -.venv -.python-version -.tool-versions - -# Env Vars - -!.envs/.local/ - -# mypy -.mypy_cache/ - -### Node template -# Logs -logs -*.log -npm-debug.log* -yarn-debug.log* -yarn-error.log* - - -### macOS template -# General -*.DS_Store -.AppleDouble -.LSOverride - -# Icon must end with two \r -Icon - -# Thumbnails -._* - - -# node - -node_modules - -# vs code - -.vscode - -### Project template - -MailHog -media/ - -.pytest_cache/ - -.env -.envs/* -envs/ - -data/ - -cruft + +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +.hypothesis/ + +# Translations +*.mo +*.pot + +# Django stuff: +staticfiles/ + +# Virtual Environments +.venv +.python-version +.tool-versions + +# Env Vars + +!.envs/.local/ + +# mypy +.mypy_cache/ + +### Node template +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* + + +### macOS template +# General +*.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + +# Thumbnails +._* + + +# node + +node_modules + +# vs code + +.vscode + +### Project template + +MailHog +media/ + +.pytest_cache/ + +.env +.envs/* +envs/ + +data/ + +cruft + +venv/ +db.sqlite3 +justfile2 +# package-lock.json +# package.json +.env.sample/* + + +**/migrations/* +!**/migrations/__init__.py diff --git a/cl8/admin.py b/cl8/admin.py index 5d769fd..13138bc 100644 --- a/cl8/admin.py +++ b/cl8/admin.py @@ -1,303 +1,306 @@ -import csv -import json -import logging -from io import StringIO - -import allauth -import django -import rest_framework -import taggit -from django import forms -from django.conf import settings -from django.contrib import messages -from django.contrib.admin.sites import AdminSite -from django.contrib.sites.models import Site -from django.core.exceptions import PermissionDenied -from django.http import HttpResponse -from django.shortcuts import redirect, render -from django.urls import path, reverse - -from cl8.users.importers import CSVImporter, FireBaseImporter -from cl8.users.models import Constellation, Profile, User - -from .users.admin import ConstellationAdmin, ProfileAdmin, UserAdmin - -logger = logging.getLogger(__name__) - - -class CsvImportForm(forms.Form): - import_file = forms.FileField() - import_photos = forms.BooleanField( - required=False, - help_text="Import photos for each profile? (This can take a long time)", - ) - - def save(self): - importer = CSVImporter() - - # we need to decode the uploaded file from bytes to string - csv_file = StringIO(self.cleaned_data["import_file"].read().decode("utf-8")) - - importer.load_csv(csv_file) - return importer.create_users(import_photos=self.cleaned_data["import_photos"]) - - -class FirebaseImportForm(forms.Form): - firebase_json = forms.FileField() - import_photos = forms.BooleanField( - required=False, - help_text="Import photos for each profile? (This can take a long time)", - ) - - def save(self): - try: - importer = FireBaseImporter() - json_content = self.cleaned_data["firebase_json"].read().decode("utf-8") - - parsed_data = json.loads(json_content) - profiles = [prof for prof in parsed_data["userlist"].values()] - return importer.add_users_from_json( - profiles, import_photos=self.cleaned_data["import_photos"] - ) - except Exception as ex: - logger.exception(ex) - raise forms.ValidationError( - "There was a problem importing your file. " - "Please check it is a valid json file." - ) - - -class ConstellationAdminSite(AdminSite): - site_header = "Constellate Admin" - site_title = "Constellate Admin Portal" - index_title = "Constellate Admin" - - def get_urls(self): - """ - Return the usual screens associated with registered models, - plus the extra urls used for non-model oriented admin pages, - """ - urls = super().get_urls() - extra_urls = [ - path( - "import-csv", - self.admin_view(self.import_csv), - name="import-profiles-from-csv", - ), - path( - "profile-import-csv-template", - self.admin_view(self.profile_import_csv_template), - name="profile-import-csv-template", - ), - path( - "import-firebase", - self.admin_view(self.import_from_firebase), - name="import-profiles-from-firebase", - ), - path( - "email-template", - self.admin_view(self.email_template), - name="email-template", - ), - ] - # order is important here. the default django admin - # has a final_catch_all_view that will swallow anything - # matching admin/, which means it will be - # matched first, and any extra urls will not be matched. - return extra_urls + urls - - def get_app_list(self, request, app_label=None): - """ - Add the links to the extra screens on the admin index - page, for importers and so on. - """ - app_list = super().get_app_list(request) - - patterns = [ - { - "name": "Utilities", - "app_label": "cl8", - "models": [ - { - "name": "Profile Import", - "object_name": "profile_import", - "admin_url": reverse("admin:import-profiles-from-csv"), - "view_only": True, - }, - { - "name": "Firebase Import", - "object_name": "firebase_import", - "admin_url": reverse("admin:import-profiles-from-firebase"), - "view_only": True, - }, - ], - } - ] - return app_list + patterns - - def import_from_firebase(self, request): - if not request.user.has_perm("profiles.import_profiles"): - raise PermissionDenied - - if request.method == "POST": - form = FirebaseImportForm(request.POST, request.FILES) - if form.is_valid(): - created_users = form.save() - - messages.add_message( - request, - messages.INFO, - ( - "Your json file with users has been imported. " - f"{len(created_users)} new profiles were imported.", - ), - ) - - return redirect("/admin/users/profile/") - - form = FirebaseImportForm() - context = {**self.each_context(request), "form": form} - return render(request, "profile_firebase_import.html", context) - - def import_csv(self, request): - if not request.user.has_perm("profiles.import_profiles"): - raise PermissionDenied - - if request.method == "POST": - form = CsvImportForm(request.POST, request.FILES) - if form.is_valid(): - created_users = form.save() - - messages.add_message( - request, - messages.INFO, - ( - "Your csv file with users has been imported. " - f"{len(created_users)} new profiles were imported.", - ), - ) - - return redirect("/admin/users/profile/") - - form = CsvImportForm() - - context = {**self.each_context(request), "form": form} - return render(request, "profile_csv_import.html", context) - - def profile_import_csv_template(self, request): - # Create the HttpResponse object with the appropriate CSV header. - response = HttpResponse(content_type="text/csv") - response["Content-Disposition"] = 'attachment; filename="sample.csv"' - - sampleProfile = { - "name": "Example name", - "admin": False, - "visible": False, - "tags": "comma, separated tags, 'here in quotes'", - "photo": "https://link.website.com/profile-photo.jpg", - "email": "email@example.com", - "phone": "07974 123 456", - "website": "https://example.com", - "twitter": "https://twitter.com/username", - "linkedin": "https://linkedin.com/username", - "facebook": "https://facebook.com/username", - "bio": "A paragraph of text. Will be rendered as markdown and can contain links.", - } - - writer = csv.writer(response) - writer.writerow(sampleProfile.keys()) - writer.writerow(sampleProfile.values()) - - return response - - def email_template(self, request): - """ - A convenience page for viewing the email templates we send out. - """ - if not request.user.has_perm("profiles.import_profiles"): - raise PermissionDenied - - current_site = Site.objects.get_current() - constellation = Constellation.objects.get(site=current_site) - support_email_address = settings.SUPPORT_EMAIL - - context = { - "profile": self, - "support_email_address": support_email_address, - "constellation": constellation, - "site": current_site, - } - - email_templates = { - "new_invite": "invite_new_profile.mjml.html", - "passwordless_email": "passwordless_default_token_email.mjml.html", - } - - template_key = request.GET.get("template_key") - logger.warn("template_key") - logger.warn(template_key) - - from django.template.loader import render_to_string - - template = "invite_new_profile.mjml.html" - if template_key is not None: - try: - # TODO: why does firefox render generated MJML as a blank screen? - template = email_templates.get(template_key) - rendered_template = render_to_string(template, context) - return django.http.response.HttpResponse(rendered_template) - except Exception as ex: - logger.error(ex) - logger.warn(f"template not found for key {template_key}") - - rendered_template = render_to_string(template, context) - return django.http.response.HttpResponse(rendered_template) - - -site = ConstellationAdminSite() - -# TODO: why does this not work? -# autodiscover_modules("admin", register_to=site) - -# we list all the models brought via libraries their full paths -# to make it easier to see where they come, from in one place - -# allauth -# used for tracking email addresses -# and for social logins via slack -site.register( - allauth.socialaccount.models.SocialAccount, - allauth.socialaccount.admin.SocialAccountAdmin, -) -site.register( - allauth.socialaccount.models.SocialApp, allauth.socialaccount.admin.SocialAppAdmin -) -site.register( - allauth.socialaccount.models.SocialToken, - allauth.socialaccount.admin.SocialTokenAdmin, -) -site.register( - allauth.account.models.EmailAddress, - allauth.account.admin.EmailAddressAdmin, -) - -# Used by DRF for token access when hitting the API -# TODO: given we no longer use Vue and the APIs it consumes should -# we remove all the API stuff? -site.register( - rest_framework.authtoken.models.Token, rest_framework.authtoken.admin.TokenAdmin -) -site.register(django.contrib.sites.models.Site, django.contrib.sites.admin.SiteAdmin) -site.register(django.contrib.auth.models.Group, django.contrib.auth.admin.GroupAdmin) -site.register( - django.contrib.flatpages.models.FlatPage, - django.contrib.flatpages.admin.FlatPageAdmin, -) - -# Used for tags -site.register(taggit.models.Tag, taggit.admin.TagAdmin) - - -site.register(Constellation, ConstellationAdmin) -site.register(Profile, ProfileAdmin) -site.register(User, UserAdmin) +import csv +import json +import logging +from io import StringIO + +import allauth +import django +import rest_framework +import taggit +from django import forms +from django.conf import settings +from django.contrib import messages +from django.contrib.admin.sites import AdminSite +from django.contrib.sites.models import Site +from django.core.exceptions import PermissionDenied +from django.http import HttpResponse +from django.shortcuts import redirect, render +from django.urls import path, reverse + +from cl8.users.importers import CSVImporter, FireBaseImporter +from cl8.users.models import Constellation, Profile, User, SendInviteEmailContent, PasswordResetEmailContent + +from .users.admin import ConstellationAdmin, ProfileAdmin, UserAdmin, SendInviteEmailContentAdmin, PasswordResetEmailContentAdmin + +logger = logging.getLogger(__name__) + + +class CsvImportForm(forms.Form): + import_file = forms.FileField() + import_photos = forms.BooleanField( + required=False, + help_text="Import photos for each profile? (This can take a long time)", + ) + + def save(self): + importer = CSVImporter() + + # we need to decode the uploaded file from bytes to string + csv_file = StringIO(self.cleaned_data["import_file"].read().decode("utf-8")) + + importer.load_csv(csv_file) + return importer.create_users(import_photos=self.cleaned_data["import_photos"]) + + +class FirebaseImportForm(forms.Form): + firebase_json = forms.FileField() + import_photos = forms.BooleanField( + required=False, + help_text="Import photos for each profile? (This can take a long time)", + ) + + def save(self): + try: + importer = FireBaseImporter() + json_content = self.cleaned_data["firebase_json"].read().decode("utf-8") + + parsed_data = json.loads(json_content) + profiles = [prof for prof in parsed_data["userlist"].values()] + return importer.add_users_from_json( + profiles, import_photos=self.cleaned_data["import_photos"] + ) + except Exception as ex: + logger.exception(ex) + raise forms.ValidationError( + "There was a problem importing your file. " + "Please check it is a valid json file." + ) + + +class ConstellationAdminSite(AdminSite): + site_header = "Constellate Admin" + site_title = "Constellate Admin Portal" + index_title = "Constellate Admin" + + def get_urls(self): + """ + Return the usual screens associated with registered models, + plus the extra urls used for non-model oriented admin pages, + """ + urls = super().get_urls() + extra_urls = [ + path( + "import-csv", + self.admin_view(self.import_csv), + name="import-profiles-from-csv", + ), + path( + "profile-import-csv-template", + self.admin_view(self.profile_import_csv_template), + name="profile-import-csv-template", + ), + path( + "import-firebase", + self.admin_view(self.import_from_firebase), + name="import-profiles-from-firebase", + ), + path( + "email-template", + self.admin_view(self.email_template), + name="email-template", + ), + ] + # order is important here. the default django admin + # has a final_catch_all_view that will swallow anything + # matching admin/, which means it will be + # matched first, and any extra urls will not be matched. + return extra_urls + urls + + def get_app_list(self, request, app_label=None): + """ + Add the links to the extra screens on the admin index + page, for importers and so on. + """ + app_list = super().get_app_list(request) + + patterns = [ + { + "name": "Utilities", + "app_label": "cl8", + "models": [ + { + "name": "Profile Import", + "object_name": "profile_import", + "admin_url": reverse("admin:import-profiles-from-csv"), + "view_only": True, + }, + { + "name": "Firebase Import", + "object_name": "firebase_import", + "admin_url": reverse("admin:import-profiles-from-firebase"), + "view_only": True, + }, + ], + } + ] + return app_list + patterns + + def import_from_firebase(self, request): + if not request.user.has_perm("profiles.import_profiles"): + raise PermissionDenied + + if request.method == "POST": + form = FirebaseImportForm(request.POST, request.FILES) + if form.is_valid(): + created_users = form.save() + + messages.add_message( + request, + messages.INFO, + ( + "Your json file with users has been imported. " + f"{len(created_users)} new profiles were imported.", + ), + ) + + return redirect("/admin/users/profile/") + + form = FirebaseImportForm() + context = {**self.each_context(request), "form": form} + return render(request, "profile_firebase_import.html", context) + + def import_csv(self, request): + if not request.user.has_perm("profiles.import_profiles"): + raise PermissionDenied + + if request.method == "POST": + form = CsvImportForm(request.POST, request.FILES) + if form.is_valid(): + created_users = form.save() + + messages.add_message( + request, + messages.INFO, + ( + "Your csv file with users has been imported. " + f"{len(created_users)} new profiles were imported.", + ), + ) + + return redirect("/admin/users/profile/") + + form = CsvImportForm() + + context = {**self.each_context(request), "form": form} + return render(request, "profile_csv_import.html", context) + + def profile_import_csv_template(self, request): + # Create the HttpResponse object with the appropriate CSV header. + response = HttpResponse(content_type="text/csv") + response["Content-Disposition"] = 'attachment; filename="sample.csv"' + + sampleProfile = { + "name": "Example name", + "admin": False, + "visible": False, + "tags": "comma, separated tags, 'here in quotes'", + "photo": "https://link.website.com/profile-photo.jpg", + "email": "email@example.com", + "phone": "07974 123 456", + "website": "https://example.com", + "twitter": "https://twitter.com/username", + "linkedin": "https://linkedin.com/username", + "facebook": "https://facebook.com/username", + "bio": "A paragraph of text. Will be rendered as markdown and can contain links.", + } + + writer = csv.writer(response) + writer.writerow(sampleProfile.keys()) + writer.writerow(sampleProfile.values()) + + return response + + def email_template(self, request): + """ + A convenience page for viewing the email templates we send out. + """ + if not request.user.has_perm("profiles.import_profiles"): + raise PermissionDenied + + current_site = Site.objects.get_current() + constellation = Constellation.objects.get(site=current_site) + support_email_address = settings.SUPPORT_EMAIL + + context = { + "profile": self, + "support_email_address": support_email_address, + "constellation": constellation, + "site": current_site, + } + + email_templates = { + "new_invite": "invite_new_profile.mjml.html", + "passwordless_email": "passwordless_default_token_email.mjml.html", + } + + template_key = request.GET.get("template_key") + logger.warn("template_key") + logger.warn(template_key) + + from django.template.loader import render_to_string + + template = "invite_new_profile.mjml.html" + if template_key is not None: + try: + # TODO: why does firefox render generated MJML as a blank screen? + template = email_templates.get(template_key) + rendered_template = render_to_string(template, context) + return django.http.response.HttpResponse(rendered_template) + except Exception as ex: + logger.error(ex) + logger.warn(f"template not found for key {template_key}") + + rendered_template = render_to_string(template, context) + return django.http.response.HttpResponse(rendered_template) + + +site = ConstellationAdminSite() + +# TODO: why does this not work? +# autodiscover_modules("admin", register_to=site) + +# we list all the models brought via libraries their full paths +# to make it easier to see where they come, from in one place + +# allauth +# used for tracking email addresses +# and for social logins via slack +site.register( + allauth.socialaccount.models.SocialAccount, + allauth.socialaccount.admin.SocialAccountAdmin, +) +site.register( + allauth.socialaccount.models.SocialApp, allauth.socialaccount.admin.SocialAppAdmin +) +site.register( + allauth.socialaccount.models.SocialToken, + allauth.socialaccount.admin.SocialTokenAdmin, +) +site.register( + allauth.account.models.EmailAddress, + allauth.account.admin.EmailAddressAdmin, +) + +# Used by DRF for token access when hitting the API +# TODO: given we no longer use Vue and the APIs it consumes should +# we remove all the API stuff? +site.register( + rest_framework.authtoken.models.Token, rest_framework.authtoken.admin.TokenAdmin +) +site.register(django.contrib.sites.models.Site, django.contrib.sites.admin.SiteAdmin) +site.register(django.contrib.auth.models.Group, django.contrib.auth.admin.GroupAdmin) +site.register( + django.contrib.flatpages.models.FlatPage, + django.contrib.flatpages.admin.FlatPageAdmin, +) + +# Used for tags +site.register(taggit.models.Tag, taggit.admin.TagAdmin) + + +site.register(Constellation, ConstellationAdmin) +site.register(Profile, ProfileAdmin) +site.register(User, UserAdmin) +site.register(SendInviteEmailContent, SendInviteEmailContentAdmin) +site.register(PasswordResetEmailContent, PasswordResetEmailContentAdmin) + diff --git a/cl8/static/css/template.css b/cl8/static/css/template.css new file mode 100644 index 0000000..b5d3e1b --- /dev/null +++ b/cl8/static/css/template.css @@ -0,0 +1,70 @@ +body { + background: #ECF0F1; +} + +/* login page */ +.login-card { + border: 1px solid #c8d4de; + border-radius: 15px; + background: #fff; + .input{ + background-color: #ECF0F1; + } +} + +.primary-btn { + background: #ffec00ff; + border-color: #c8d4de; + color: #000; + + &:hover { + border-color: #c8d4de; + background: #ffc107ff; + } +} + +.secondary-btn { + background: #c8d4deff; + color: #000; +} + +.profile-invisible-btn { + background: #ecf0f1ff; + border: 1px dashed #c8d4de; + color: #000; +} + +.profile-visible-btn { + background: #8bc34aff; + border: 2px solid #c8d4de; + color: #fff; +} + +.border-gray { + border: 1px solid #c8d4de; +} + +.tags-view { + border: 1px solid #c8d4de; + background: #ecf0f1ff; + + &:hover { + background: #cdddea; + border-color: #1a3844; + color: #1a3844; + } +} + + +.empty-field { + border: 2px dashed #c8d4deff !important; +} + +.fill-field { + border: 1px solid #c8d4deff !important; + background: #c8d4deff !important; +} + +.input:focus { + outline: none; +} \ No newline at end of file diff --git a/cl8/static/images/magnifying-glass.png b/cl8/static/images/magnifying-glass.png new file mode 100644 index 0000000000000000000000000000000000000000..71f49f81409165016695acd00cc2b2d1df22b65e GIT binary patch literal 400 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`EX7WqAsj$Z!;#Vf4nJ zum^)MqamXgNRecTYeY$Kep*R+Vo@qXd3m{BW?pu2a$-TMUVc&f>~}U&3=E9Co-U3d z7QIt1ZuDXf6li^Teu`7a+__Qe3Ch=+6ql}z?OJj@sJ5}>+ezUg;Tr;L8XJ0B6BIWJ zY8&=Or0AB3neD!rA=bvd`T3vncaolyPH^S+eC+YJNoa73+x~#_v6|bA_K$q#60^y9_E>x+ECaNyPm1#gVC~_XTMElSSr)LmdmGG z-(Qm`Enh$5sfo1f0p)N18BdtJKPu0h+2LNFQlQOSv|1v`dDnCCHz70DFJYW!HkH%r zl5PD%ruG|0J@3qM`MV&TiDgNvr{A2bJckX`Ki;Y1oWN{a*j79zE#FQ54EKan7qm9L q5LdXnmBCx#@>WL1mq}f(;~m - {% for active_tag in active_tags %} -
- - -
- {% endfor %} - +
+ Filter + {% for active_tag in active_tags %} +
+ + +
+ {% endfor %} +
diff --git a/cl8/templates/_nav_menu.html b/cl8/templates/_nav_menu.html index b023b6d..36c8444 100644 --- a/cl8/templates/_nav_menu.html +++ b/cl8/templates/_nav_menu.html @@ -1,91 +1,125 @@ -{% load static %} -{% load account socialaccount %} - -
- {% comment %} - empty div here and at the end gives us our mx-auto style spacing - using css grid - {% endcomment %} -
-
-
-
- -
- -
-
-
-
-
-
+{% load static %} +{% load static widget_tweaks %} +{% load account socialaccount %} + +
+ {% comment %} + empty div here and at the end gives us our mx-auto style spacing + using css grid + {% endcomment %} +
+
+
+
+
+ +
+
+
+
+
+
+ +
+
+ {% render_field profile_filter.form.bio hx-get="/" hx-trigger="keyup changed delay:0.1s," hx-target=".sidebar" class="text-xl rounded-xl w-full " hx-sync="closest form:abort" %} + {% if profile_filter %} + + {% endif %} +
+ + +
+
+
+
+
+
+ {% comment %} close search component {% endcomment %} +
+
+
+ +
+
+
+
+
+
diff --git a/cl8/templates/_profile.html b/cl8/templates/_profile.html index 7b3f3cc..3e90c8e 100644 --- a/cl8/templates/_profile.html +++ b/cl8/templates/_profile.html @@ -1,154 +1,134 @@ -{% load gravatar %} -
- -
-
- {% if profile.photo %} - profile photo - {% else %} - {% gravatar profile.user.email 150 "Profile via gravatar.com" "rounded" %} - {% endif %} -
-
-

{{ profile.name }}

- {% comment %} {% if profile.email %} -

- {{ profile.email }} -

- {% endif %} - {% if profile.phone %}

{{ profile.phone }}

{% endif %} {% endcomment %} - {% if profile.website %} -

- {{ profile.website }} -

- {% endif %} - {% if profile.location %}

{{ profile.location }}

{% endif %} -

- {% if profile.twitter %} - Twitter | - {% endif %} - {% if profile.facebook %} - Facebook | - {% endif %} - {% if profile.linkedin %} - LinkedIn - {% endif %} -

- {% if can_edit %} - Edit - {% endif %} -
-
-
- {% if profile_rendered_bio %} - {% comment %}

Bio

{% endcomment %} -
{{ profile_rendered_bio|safe }}
- {% endif %} -
- {% if grouped_tags %} - {% for tag_group, tags in grouped_tags.items %} -

{{ tag_group }}

-

- {% for tag_dict in tags %} - - {% endfor %} -

- {% endfor %} - {% endif %} - {% if ungrouped_tags %} - {% comment %}

Tags

{% endcomment %} -

- {% for tag_dict in ungrouped_tags %} - - {% endfor %} -

- {% endif %} -
-
-
- +{% load gravatar %} +
+ +
+
+ {% if profile.photo %} + profile photo + {% else %} + {% gravatar profile.user.email 150 "Profile via gravatar.com" "rounded-full" %} + {% endif %} +
+
+

{{ profile.name }}

+ {% comment %} {% if profile.email %} +

+ {{ profile.email }} +

+ {% endif %} + {% if profile.phone %}

{{ profile.phone }}

{% endif %} {% endcomment %} + {% if profile.website %} +

+ {{ profile.website }} +

+ {% endif %} + {% if profile.location %}

{{ profile.location }}

{% endif %} + {% if profile.organisation %}

{{ profile.organisation }}

{% endif %} +

+ {% if profile.social_1 %} + {{ profile.social_1_name }} + {% endif %} + {% if profile.social_2 %} + | {{ profile.social_2_name }} + {% endif %} + {% if profile.social_3 %} + | {{ profile.social_3_name }} + {% endif %} +

+ {% if can_edit %} + Edit + {% endif %} +
+
+
+ {% if profile_rendered_bio %} + {% comment %}

Bio

{% endcomment %} +
{{ profile_rendered_bio|safe }}
+ {% endif %} +
+ {% if grouped_tags %} + {% for tag_group, tags in grouped_tags.items %} +

{{ tag_group }}

+

+ {% for tag_dict in tags %} + + {% endfor %} +

+ {% endfor %} + {% endif %} + {% if ungrouped_tags %} + {% comment %}

Tags

{% endcomment %} +

+ {% for tag_dict in ungrouped_tags %} + + {% endfor %} +

+ {% endif %} +
+
+
+ diff --git a/cl8/templates/_profile_create.html b/cl8/templates/_profile_create.html index 2d3d9a4..a0fbf2d 100644 --- a/cl8/templates/_profile_create.html +++ b/cl8/templates/_profile_create.html @@ -1,143 +1,222 @@ -{% load static widget_tweaks %} - -
-
- {% csrf_token %} - {% if form.non_field_errors %} -
- {{ form.non_field_errors }} -
- {% endif %} -
-
- {% comment %} {{ form.photo }} {% endcomment %} -

{{ form.photo }}

-

- {% render_field form.visible class="checkbox" %} - -

-

When 'visible' is checked, your profile will show up in searches

-
-
-
- - {{ form.name.errors }} - -
-
- - {{ form.email.errors }} - -
-
- - {{ form.organisation.errors }} - -
-
- - {{ form.location.errors }} - -
-
- - {{ form.linkedin.errors }} - -
-
- - {{ form.twitter.errors }} - -
-
- - {{ form.facebook.errors }} - -
-
- - {{ form.bio.errors }} - {% render_field form.bio class="textarea textarea-bordered w-full" placeholder="Type here" rows="5" cols="40" %} -
-
- - {% render_field form.tags class="w-full mt-4" %} -
-
-
- - Back -
-
- -{{ form.media }} +{% load static widget_tweaks %} + +
+
+ {% csrf_token %} + {% if form.non_field_errors %} +
+ {{ form.non_field_errors }} +
+ {% endif %} +
+ +
+
+ + {% comment %} Email password reset {% endcomment %} + Cancel +
+
+ + {{ form.name.errors }} + +
+
+ + {{ form.email.errors }} + +
+ +
+ + {{ form.website.errors }} + +
+ +
+ + {{ form.organisation.errors }} + +
+
+ + {{ form.location.errors }} + +
+
+ + {{ form.social_1.errors }} + +
+
+ + {{ form.social_2.errors }} + +
+
+ + {{ form.social_3.errors }} + +
+
+ + {{ form.bio.errors }} + {% render_field form.bio class="textarea textarea-bordered empty-field myInput w-full" placeholder="Bio" rows="5" cols="40" %} +
+
+ + {% render_field form.tags class="w-full mt-4" %} +
+
+ + Cancel +
+
+
+ +
+
+ + + +{{ form.media }} \ No newline at end of file diff --git a/cl8/templates/_profile_edit.html b/cl8/templates/_profile_edit.html index 1feca9c..8c66032 100644 --- a/cl8/templates/_profile_edit.html +++ b/cl8/templates/_profile_edit.html @@ -1,138 +1,255 @@ -{% load static widget_tweaks %} - -
-
- {% csrf_token %} -
-
- {% if form.instance.photo %} - - {% else %} - - {% endif %} - {% comment %} {{ form.photo }} {% endcomment %} -

{{ form.photo }}

-

- {% render_field form.visible class="checkbox" %} - -

-

When 'visible' is checked, your profile will show up in searches

-
-
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - {% render_field form.bio class="textarea textarea-bordered w-full" placeholder="Type here" rows="5" cols="40" %} -
-
- - {% render_field form.tags class="w-full mt-4" %} -
-
-
- - Back -
-
- -{{ form.media }} +{% load static widget_tweaks %} + + + +
+
+ {% if form.errors %} +
+ {{ form.errors }} +
+ {% endif %} + {% csrf_token %} +
+ + + + +
+
+ + {% if request.user.is_superuser %} + Email password reset + {% endif %} + Cancel +
+ +
+ {{ form.name.errors }} + +
+ +
+ {{ form.email.errors }} + +
+ +
+ {{ form.website.errors }} + +
+ +
+ {{ form.organisation.errors }} + +
+ +
+ {{ form.location.errors }} + +
+ +
+ {{ form.phone.errors }} + +
+ +
+ {{ form.social_1.errors }} + +
+ +
+ {{ form.social_2.errors }} + +
+ +
+ {{ form.social_3.errors }} + +
+ +
+ {{ form.bio.errors }} + {% render_field form.bio class="textarea textarea-bordered empty-field myInput w-full" placeholder="Bio" rows="5" %} +
+ +
+ + {% render_field form.tags class="w-full mt-4" %} +
+ +
+ + Cancel +
+
+
+ + +
+
+ +{{ form.media }} + + + + diff --git a/cl8/templates/_profile_empty.html b/cl8/templates/_profile_empty.html index 2339772..5f1bef4 100644 --- a/cl8/templates/_profile_empty.html +++ b/cl8/templates/_profile_empty.html @@ -1,12 +1,19 @@ -
-

- Find people in the {{ request.constellation }} community based on what they do, where they live, what they're asking for help with, and what they're offering to other members. -

-

- Either search for people using the search bar above, or click on a profile to the left. -

-

- Want to see what people see for your profile? - Go to your profile where you can make changes. -

-
+
+

Welcome

+ + {% if request.constellation.welcome_message %} +

{{request.constellation.welcome_message}}

+ {% else %} +

Find Icebreakers based on common interests

+ {% endif %} + Edit your own profile +
diff --git a/cl8/templates/account/_nav_logged_out.html b/cl8/templates/account/_nav_logged_out.html index 98be26b..853a897 100644 --- a/cl8/templates/account/_nav_logged_out.html +++ b/cl8/templates/account/_nav_logged_out.html @@ -1,30 +1,30 @@ -{% load static %} -{% load account socialaccount %} - -
-
- -
-
+{% load static %} +{% load account socialaccount %} + +
+
+ +
+
diff --git a/cl8/templates/account/base.html b/cl8/templates/account/base.html index 514ebf2..d3881c6 100644 --- a/cl8/templates/account/base.html +++ b/cl8/templates/account/base.html @@ -1,10 +1,10 @@ -{% extends "base.html" %} -{% load static %} -{% load account socialaccount %} -{% block title %} - {% block head_title %} - {% endblock head_title %} -{% endblock title %} -{% block nav %} - {% include "account/_nav_logged_out.html" %} -{% endblock nav %} +{% extends "base.html" %} +{% load static %} +{% load account socialaccount %} +{% block title %} + {% block head_title %} + {% endblock head_title %} +{% endblock title %} +{% block nav %} + {% include "_nav_menu.html" %} +{% endblock nav %} diff --git a/cl8/templates/account/email/account_already_exists_message.html b/cl8/templates/account/email/account_already_exists_message.html new file mode 100644 index 0000000..694a101 --- /dev/null +++ b/cl8/templates/account/email/account_already_exists_message.html @@ -0,0 +1,13 @@ +{% extends "account/email/base_message.txt" %} +{% load i18n %} + +{% block content %}{% autoescape off %}{% blocktrans %}You are receiving this email because you or someone else tried to signup for an +account using email address: + +{{ email }} + +However, an account using88888888888888888888888888888888888888888888888 that email address already exists. In case you have +forgotten about this, please use the password forgotten procedure to recover +your account: + +{{ password_reset_url }}{% endblocktrans %}{% endautoescape %}{% endblock content %} diff --git a/cl8/templates/account/email/email_confirmation_signup_message.html b/cl8/templates/account/email/email_confirmation_signup_message.html new file mode 100644 index 0000000..ed7b7e7 --- /dev/null +++ b/cl8/templates/account/email/email_confirmation_signup_message.html @@ -0,0 +1,23 @@ + + + + {% if request.email_confirmation.email_title %} + {{request.email_confirmation.email_title}} + {% else %} + Confirm your email address + {% endif %} + + + + {% if request.email_confirmation.email_content %} + {{request.email_confirmation.email_content}} + {% else %} +

Hello {{ user.username }},

+

Thank you for registering at Constellate. To complete your registration, please confirm your email address by clicking the link below:

+

Confirm your email address {{ activate_url }}

+

If you did not request this email, please ignore it.

+

Thank you for using Constellate!

+ {% endif %} + + + \ No newline at end of file diff --git a/cl8/templates/account/login.html b/cl8/templates/account/login.html index ca96231..697fea3 100644 --- a/cl8/templates/account/login.html +++ b/cl8/templates/account/login.html @@ -1,81 +1,80 @@ -{% extends "account/base.html" %} -{% load i18n %} -{% load account socialaccount %} -{% block head_title %} - {% trans "Sign In" %} -{% endblock %} -{% block inner %} -
-

{% trans "Sign in" %}

- {% get_providers as socialaccount_providers %} - {% if request.constellation.signin_via_slack %} - {% if socialaccount_providers %} -
-
-
    - {% include "socialaccount/snippets/provider_list.html" with process="login" %} -
- {% comment %} {% endcomment %} -
- {% comment %} {% include "socialaccount/snippets/login_extra.html" %} {% endcomment %} - {% else %} -

- {% blocktrans %}If you have not created an account yet, then please -sign up first.{% endblocktrans %} -

- {% endif %} - {% endif %} - {% if request.constellation.signin_via_email %} - {% comment %} normal sign in flow {% endcomment %} -
-

- -

- {% endif %} -
-
- {% endblock %} +{% extends "account/base.html" %} +{% load i18n %} +{% load account socialaccount %} +{% block head_title %} +{% trans "Sign In" %} +{% endblock %} +{% block inner %} + +{% endblock %} \ No newline at end of file diff --git a/cl8/templates/account/logout.html b/cl8/templates/account/logout.html index 067f25f..73e02e0 100644 --- a/cl8/templates/account/logout.html +++ b/cl8/templates/account/logout.html @@ -1,22 +1,24 @@ -{% extends "account/base.html" %} -{% load i18n %} -{% block head_title %} - {% trans "Sign Out" %} -{% endblock %} -{% block inner %} -
-

{% trans "Sign Out" %}

-

{% trans 'Are you sure you want to sign out?' %}

-
-
- {% csrf_token %} - {% if redirect_field_value %} - - {% endif %} - -
-
-
-{% endblock %} +{% extends "account/base.html" %} +{% load i18n %} +{% block head_title %} +{% trans "Sign Out" %} +{% endblock %} +{% block inner %} +
+
+
+

{% trans "Sign Out" %}

+

{% trans 'Are you sure you want to sign out?' %}

+ +
+ {% csrf_token %} + {% if redirect_field_value %} + + {% endif %} + + +
+
+
+
+{% endblock %} \ No newline at end of file diff --git a/cl8/templates/account/password_reset.html b/cl8/templates/account/password_reset.html index 44a564b..532df41 100644 --- a/cl8/templates/account/password_reset.html +++ b/cl8/templates/account/password_reset.html @@ -1,37 +1,45 @@ -{% extends "account/base.html" %} -{% load i18n %} -{% load account %} -{% block head_title %} - {% trans "Password Reset" %} -{% endblock %} -{% block inner %} -
-

{% trans "Password Reset" %}

- {% if user.is_authenticated %} - {% include "account/snippets/already_logged_in.html" %} - {% endif %} -
-

- {% trans "Forgotten your password? Enter your e-mail address below, and we'll send you an e-mail allowing you to reset it." %} -

-
- {% csrf_token %} -
- - -
- -
-

{% blocktrans %}Please contact us if you have any trouble resetting your password.{% endblocktrans %}

-
-
-{% endblock %} +{% extends "account/base.html" %} +{% load i18n %} +{% load account %} +{% block head_title %} {% trans "Password Reset" %} {% endblock %} {% block inner %} +
+
+
+

{% trans "Password Reset" %}

+ {% if user.is_authenticated %} {% include "account/snippets/already_logged_in.html" %} {% endif %} +
+

+ {% trans "Forgotten your password? Enter your e-mail address below, and we'll send you an e-mail allowing you to reset it." %} +

+
+ {% csrf_token %} +
+ {% comment %} {% endcomment %} + +
+ +
+

+ {% blocktrans %}Please contact us if you have any trouble resetting + your password.{% endblocktrans %} +

+
+
+
+
+{% endblock %} diff --git a/cl8/templates/account/password_reset_done.html b/cl8/templates/account/password_reset_done.html index 6b56dd5..f13eb3d 100644 --- a/cl8/templates/account/password_reset_done.html +++ b/cl8/templates/account/password_reset_done.html @@ -1,19 +1,28 @@ -{% extends "account/base.html" %} -{% load i18n %} -{% load account %} -{% block head_title %} - {% trans "Password Reset" %} -{% endblock %} -{% block inner %} -
-

{% trans "Password Reset" %}

- {% if user.is_authenticated %} - {% include "account/snippets/already_logged_in.html" %} - {% endif %} -
-

- {% blocktrans %}We have sent you an e-mail. Please contact us if you do not receive it within a few minutes.{% endblocktrans %} -

-
-
-{% endblock %} +{% extends "account/base.html" %} +{% load i18n %} +{% load account %} +{% block head_title %} + +{% trans "Password Reset" %} +{% endblock %} +{% block inner %} +
+
+
+

{% trans "Password Reset" %}

+ {% if user.is_authenticated %} + {% include "account/snippets/already_logged_in.html" %} + {% endif %} +
+

+ {% blocktrans %}We have sent you an e-mail. Please contact us if you do not receive it within a few + minutes.{% endblocktrans %} +

+
+
+
+
+ +{% endblock %} \ No newline at end of file diff --git a/cl8/templates/account/snippets/already_logged_in.html b/cl8/templates/account/snippets/already_logged_in.html new file mode 100644 index 0000000..d3d2fe3 --- /dev/null +++ b/cl8/templates/account/snippets/already_logged_in.html @@ -0,0 +1,11 @@ +{% load i18n %} +{% load account %} +{% load allauth %} +{% load custom_tags %} +{% user_display user as user_display %} + +{% element alert %} + {% slot message %} + {% blocktranslate %}Note{% endblocktranslate %}: {% blocktranslate %}You are already logged in as {% endblocktranslate %}{{ user|custom_user_display }}. + {% endslot %} +{% endelement %} diff --git a/cl8/templates/invite_new_profile.mjml.html b/cl8/templates/invite_new_profile.mjml.html index 9e0b540..6e71436 100644 --- a/cl8/templates/invite_new_profile.mjml.html +++ b/cl8/templates/invite_new_profile.mjml.html @@ -1,24 +1,29 @@ -{% load mjml %} -{% mjml %} - - -Welcome to {{ constellation.site.name }} - - - - - -

Dear {{ profile.name }},

-

Welcome to {{ constellation }}.

-

- (Copy goes here to mention terms of service and community guidelines) -

-

- If you have any problems logging in, or you have not attempted to log in, please contact support. -

-
-
-
-
-
-{% endmjml %} +{% load mjml %} +{% mjml %} + + +Welcome to {{ constellation.site.name }} + + + + + + {% if email_confirmation_content %} + {{ email_confirmation_content | safe }} + {% else %} +

Dear {{ profile.name }},

+

Welcome to {{ constellation }}.

+

+ (Copy goes here to mention terms of service and community guidelines) +

+

+ If you have any problems logging in, or you have not attempted to log in, please contact support. +

+ {% endif %} + +
+
+
+
+
+{% endmjml %} diff --git a/cl8/templates/pages/_paginated_profiles.html b/cl8/templates/pages/_paginated_profiles.html index bbd461e..8f4c408 100644 --- a/cl8/templates/pages/_paginated_profiles.html +++ b/cl8/templates/pages/_paginated_profiles.html @@ -1,46 +1,120 @@ -{% load gravatar %} -
- {% for profile in paginated_profiles %} -
- {% comment %} - We want our whole div to be clickable, hence the htmx-get, and cursor-pointer - We use flex box row to have the image and text blocks side by side - {% endcomment %} -
-
- {% if profile.photo %} - - {% else %} - {% gravatar profile.user.email 150 "Profile via gravatar.com" "rounded" %} - {% endif %} -
- -
-
- {% endfor %} -
+{% load gravatar %} +
+ {% for profile in paginated_profiles %} +
+ {% comment %} + We want our whole div to be clickable, hence the htmx-get, and cursor-pointer + We use flex box row to have the image and text blocks side by side + {% endcomment %} +
+
+ {% if profile.photo %} + + {% else %} + {% gravatar profile.user.email 100 "Profile via gravatar.com" "rounded-full" %} + {% endif %} +
+
+

+ + {{ profile.name }} + {% comment %} + Rank refers to the search rank scoring in Django postgres. + {{ profile.rank }} + See fetch_profile_list() in the view for more info, and for surfacing these numbers + if necessary. + {% endcomment %} + +

+

+ {% comment %} {% for tag in profile.tags_with_no_grouping|slice:":4" %} + {{ tag.name }} + {% endfor %} {% endcomment %} + + {% for tag in profile.tags_with_no_grouping|slice:":4" %} + + {% endfor %} + + +

+
+
+
+ {% endfor %} +
+ + diff --git a/cl8/templates/pages/create_profile.html b/cl8/templates/pages/create_profile.html index f7069a0..114eac9 100644 --- a/cl8/templates/pages/create_profile.html +++ b/cl8/templates/pages/create_profile.html @@ -1,11 +1,11 @@ -{% extends "base.html" %} -{% load static %} -{% block content %} -
-
-
-
{% include "_profile_create.html" %}
-
-
-
-{% endblock content %} +{% extends "base.html" %} +{% load static %} +{% block content %} +
+
+
+
{% include "_profile_create.html" %}
+
+
+
+{% endblock content %} diff --git a/cl8/templates/pages/edit_profile.html b/cl8/templates/pages/edit_profile.html index 42a94da..c8203a0 100644 --- a/cl8/templates/pages/edit_profile.html +++ b/cl8/templates/pages/edit_profile.html @@ -1,11 +1,11 @@ -{% extends "base.html" %} -{% load static %} -{% block content %} -
-
-
-
{% include "_profile_edit.html" %}
-
-
-
-{% endblock content %} +{% extends "base.html" %} +{% load static %} +{% block content %} +
+
+
+
{% include "_profile_edit.html" %}
+
+
+
+{% endblock content %} diff --git a/cl8/templates/pages/home.html b/cl8/templates/pages/home.html index a0b4fac..bec6516 100644 --- a/cl8/templates/pages/home.html +++ b/cl8/templates/pages/home.html @@ -1,167 +1,176 @@ -{% extends "base.html" %} -{% load static widget_tweaks %} -{% block content %} -
- {% comment %} add our search component spanning the full width {% endcomment %} -
-
-
-
-
-
-
-
- {% comment %}

- -

{% endcomment %} - {% render_field profile_filter.form.bio hx-get="/" hx-trigger="keyup changed delay:0.1s," hx-target=".sidebar" class="text-xl min-w-[100%]" hx-sync="closest form:abort" %} -
-
- -
-
- -
-
{% include '_active_tags_list.html' %}
-
- {% if active_search %} -

- {{ paginated_profiles|length }} matching profile{{ paginated_profiles|pluralize }} found -

- {% endif %} -
-
-
-
-
- {% comment %} close search component {% endcomment %} -
- {% comment %} main content {% endcomment %} -
-
- {% comment %} add our two columns for the sidebar and profile {% endcomment %} - {% comment %}
{% endcomment %} -
- {% comment %} list the active tags - {% endcomment %} - {% comment %} - We only want to hide the list of profiles when there is NO filtered search - and when a user is not selected. - {% endcomment %} - -
- {% if profile %} - {% comment %} show the profile with all the tags, and details {% endcomment %} - {% include '_profile.html' %} - {% else %} - {% comment %} otherwise show a blank "start" view with instructions {% endcomment %} - {% include "_profile_empty.html" %} - {% endif %} -
-
-
-
- {% comment %} - Add hidden version of empty profile content, used when clearing a select - profile - {% endcomment %} - -
-{% endblock content %} -{% block scripts %} - {{ block.super }} - {% comment %} TODO - can we drop jquery here? we only use it on the edit forms now {% endcomment %} - - {{ profile_filter.form.media }} - - -{% endblock scripts %} +{% extends "base.html" %} +{% load static widget_tweaks %} +{% block content %} +
+ {% comment %} add our search component spanning the full width {% endcomment %} + +
+ {% comment %} close search component {% endcomment %} + + {% comment %} main content {% endcomment %} +
+
+ {% comment %} add our two columns for the sidebar and profile {% endcomment %} + {% comment %}
{% endcomment %} +
+ {% comment %} list the active tags + {% endcomment %} + {% comment %} + We only want to hide the list of profiles when there is NO filtered search + and when a user is not selected. + {% endcomment %} + +
+ {% if profile %} + {% comment %} show the profile with all the tags, and details {% endcomment %} + {% include '_profile.html' %} + {% else %} + {% comment %} otherwise show a blank "start" view with instructions {% endcomment %} + {% include "_profile_empty.html" %} + {% endif %} +
+
+
+
+ {% comment %} + Add hidden version of empty profile content, used when clearing a select + profile + {% endcomment %} + +
+{% endblock content %} +{% block scripts %} + {{ block.super }} + {% comment %} TODO - can we drop jquery here? we only use it on the edit forms now {% endcomment %} + + {{ profile_filter.form.media }} + + + +{% endblock scripts %} diff --git a/cl8/users/admin.py b/cl8/users/admin.py index 9b2d704..7743140 100644 --- a/cl8/users/admin.py +++ b/cl8/users/admin.py @@ -1,136 +1,148 @@ -from django.contrib import admin, messages -from django.contrib.auth import admin as auth_admin -from django.contrib.auth import get_user_model -from django.forms import ModelForm -from django.utils.safestring import mark_safe -from django.utils.translation import ngettext -from taggit.forms import TagField -from taggit.models import Tag -from taggit_labels.widgets import LabelWidget - -# from .site_admin import constellation_admin -from cl8.users.forms import UserChangeForm, UserCreationForm -from cl8.users.models import Cluster, Constellation, Profile - -User = get_user_model() - - -class ProfileAdminForm(ModelForm): - tags = TagField(required=False, widget=LabelWidget) - # clusters = TagField( - # required=False, - # widget=LabelWidget(model=Cluster), - # help_text="Clusters the user wants to be included in", - # ) - - -@admin.register(User) -class UserAdmin(auth_admin.UserAdmin): - form = UserChangeForm - # add_form = UserCreationForm - # fieldsets = (("User", {"fields": ("name",)}),) + auth_admin.UserAdmin.fieldsets - list_display = ["username", "name", "is_superuser"] - search_fields = ["name"] - readonly_fields = ["last_login", "date_joined"] - - # def get_fieldsets(self, request, *args, **kwargs): - # if request.user.is_superuser: - # return ( - # (None, {"fields": ("username", "password")}), - # ( - # "Personal info", - # { - # "fields": ( - # "name", - # "email", - # ) - # }, - # ), - # ( - # "Permissions", - # { - # "fields": ( - # "is_active", - # "is_staff", - # "is_superuser", - # "groups", - # "user_permissions", - # ), - # }, - # ), - # ("Important dates", {"fields": ("last_login", "date_joined")}), - # ) - - # return ( - # (None, {"fields": ("username", "password")}), - # ( - # "Personal info", - # { - # "fields": ( - # "name", - # "email", - # ) - # }, - # ), - # ("Important dates", {"fields": ("last_login", "date_joined")}), - # ) - - -# @admin.register(Profile, site=constellation_admin) -@admin.register(Profile) -class ProfileAdmin(admin.ModelAdmin): - form = ProfileAdminForm - list_display = ["name", "email", "visible"] - search_fields = ["user__name", "user__email", "tags__name"] - actions = ["make_visible", "make_invisible", "send_invite_mail"] - - def has_set_visibility_permission(self, request): - opts = self.opts - codename = "set_visibility" - return request.user.has_perm(f"{opts.app_label}.{codename}") - - def has_send_invite_mail_permission(self, request): - opts = self.opts - codename = "send_invite_email" - return request.user.has_perm(f"{opts.app_label}.{codename}") - - @admin.action( - permissions=["set_visibility"], - description="Make visible", - ) - def make_visible(self, request, queryset): - queryset.update(visible=True) - - @admin.action( - permissions=["set_visibility"], - description="Make invisible", - ) - def make_invisible(self, request, queryset): - queryset.update(visible=False) - - @admin.action( - permissions=["send_invite_mail"], - description="Send invite email", - ) - def send_invite_mail(self, request, queryset): - sent_emails = [] - for profile in queryset: - result = profile.send_invite_mail() - sent_emails.append(result) - - self.message_user( - request, - ngettext( - "%d invite successfully sent.", - "%d invites successfully sent.", - len(sent_emails), - ) - % len(sent_emails), - messages.SUCCESS, - ) - - -@admin.register(Constellation) -class ConstellationAdmin(admin.ModelAdmin): - class Meta: - model = Constellation +from django.contrib import admin, messages +from django.contrib.auth import admin as auth_admin +from django.contrib.auth import get_user_model +from django.forms import ModelForm +from django.utils.safestring import mark_safe +from django.utils.translation import ngettext +from taggit.forms import TagField +from taggit.models import Tag +from taggit_labels.widgets import LabelWidget + +# from .site_admin import constellation_admin +from cl8.users.forms import UserChangeForm, UserCreationForm +from cl8.users.models import Cluster, Constellation, Profile, SendInviteEmailContent, PasswordResetEmailContent + +User = get_user_model() + + +class ProfileAdminForm(ModelForm): + tags = TagField(required=False, widget=LabelWidget) + # clusters = TagField( + # required=False, + # widget=LabelWidget(model=Cluster), + # help_text="Clusters the user wants to be included in", + # ) + + +@admin.register(User) +class UserAdmin(auth_admin.UserAdmin): + form = UserChangeForm + # add_form = UserCreationForm + # fieldsets = (("User", {"fields": ("name",)}),) + auth_admin.UserAdmin.fieldsets + list_display = ["username", "name", "is_superuser"] + search_fields = ["name"] + readonly_fields = ["last_login", "date_joined"] + + # def get_fieldsets(self, request, *args, **kwargs): + # if request.user.is_superuser: + # return ( + # (None, {"fields": ("username", "password")}), + # ( + # "Personal info", + # { + # "fields": ( + # "name", + # "email", + # ) + # }, + # ), + # ( + # "Permissions", + # { + # "fields": ( + # "is_active", + # "is_staff", + # "is_superuser", + # "groups", + # "user_permissions", + # ), + # }, + # ), + # ("Important dates", {"fields": ("last_login", "date_joined")}), + # ) + + # return ( + # (None, {"fields": ("username", "password")}), + # ( + # "Personal info", + # { + # "fields": ( + # "name", + # "email", + # ) + # }, + # ), + # ("Important dates", {"fields": ("last_login", "date_joined")}), + # ) + + +# @admin.register(Profile, site=constellation_admin) +@admin.register(Profile) +class ProfileAdmin(admin.ModelAdmin): + form = ProfileAdminForm + list_display = ["name", "email", "visible"] + search_fields = ["user__name", "user__email", "tags__name"] + actions = ["make_visible", "make_invisible", "send_invite_mail"] + + def has_set_visibility_permission(self, request): + opts = self.opts + codename = "set_visibility" + return request.user.has_perm(f"{opts.app_label}.{codename}") + + def has_send_invite_mail_permission(self, request): + opts = self.opts + codename = "send_invite_email" + return request.user.has_perm(f"{opts.app_label}.{codename}") + + @admin.action( + permissions=["set_visibility"], + description="Make visible", + ) + def make_visible(self, request, queryset): + queryset.update(visible=True) + + @admin.action( + permissions=["set_visibility"], + description="Make invisible", + ) + def make_invisible(self, request, queryset): + queryset.update(visible=False) + + @admin.action( + permissions=["send_invite_mail"], + description="Send invite email", + ) + def send_invite_mail(self, request, queryset): + sent_emails = [] + for profile in queryset: + result = profile.send_invite_mail() + sent_emails.append(result) + + self.message_user( + request, + ngettext( + "%d invite successfully sent.", + "%d invites successfully sent.", + len(sent_emails), + ) + % len(sent_emails), + messages.SUCCESS, + ) + + +@admin.register(Constellation) +class ConstellationAdmin(admin.ModelAdmin): + class Meta: + model = Constellation + + +@admin.register(SendInviteEmailContent) +class SendInviteEmailContentAdmin(admin.ModelAdmin): + class Meta: + model = SendInviteEmailContent + +@admin.register(PasswordResetEmailContent) +class PasswordResetEmailContentAdmin(admin.ModelAdmin): + class Meta: + model = PasswordResetEmailContent + \ No newline at end of file diff --git a/cl8/users/api/views.py b/cl8/users/api/views.py index 57e5f25..8984394 100644 --- a/cl8/users/api/views.py +++ b/cl8/users/api/views.py @@ -1,519 +1,649 @@ -import logging -from django_htmx.http import trigger_client_event -from django.template.loader import render_to_string -from dal import autocomplete -from django.conf import settings -from django.contrib.auth import get_user_model -from django.contrib.auth.decorators import login_required -from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin -from django.contrib.auth.models import Group -from django.core import paginator -from django.core.files.images import ImageFile -from django.db.models import Case, When -from django.http import HttpRequest -from django.shortcuts import render -from django.urls import resolve -from django.utils.text import slugify -from django.views.generic import DetailView, UpdateView -from django.views.generic.edit import CreateView -from markdown_it import MarkdownIt -from rest_framework import status -from rest_framework.decorators import action -from rest_framework.generics import get_object_or_404 -from rest_framework.mixins import ( - CreateModelMixin, - ListModelMixin, - RetrieveModelMixin, - UpdateModelMixin, -) - -from rest_framework.parsers import FormParser, MultiPartParser -from rest_framework.response import Response -from rest_framework.views import APIView -from rest_framework.viewsets import GenericViewSet -from taggit.models import Tag - -from ..filters import ProfileFilter -from ..forms import ProfileUpdateForm, ProfileCreateForm -from ..models import Cluster, Profile -from .serializers import ( - ClusterSerializer, - ProfilePicSerializer, - ProfileSerializer, - TagSerializer, -) - -User = get_user_model() - - -logger = logging.getLogger(__name__) -logger.setLevel(logging.DEBUG) -NO_PROFILES_PER_PAGE = 100 - - -def fetch_profile_list(request: HttpRequest, ctx: dict): - """ - Fetch the list of profiles for the given set of params, and - populate the provided context dictionary - """ - filtered_profiles = ProfileFilter( - request.GET, - queryset=Profile.objects.filter(visible=True).prefetch_related("tags", "user"), - ) - - ctx["profile_filter"] = filtered_profiles - - # because we're using full text search with postgres, we need to de-dupe the results - # while preserving their order. We'd normally do this using a distinct() call, but - # because we have multiple instances of the same profile in the queryset, with different - # 'rank' scores. The ORM with Postgres does not let us order by 'rank' if we don't include - # it as a field we are calling distinct on, and doing that would stop us being able to dedupe - # the results. - # So instead we need to manually dedupe the results by id, and then order by that. - ordered_profile_ids = [] - for prof in filtered_profiles.qs: - if prof.id not in ordered_profile_ids: - ordered_profile_ids.append(prof.id) - # once we have a deduped list of ids, we need to convert it back into a queryset, - # so code that expects a queryset can still work. - # Querysets do not guarantee order so for Postgres we need to Case() to create a - # SQL statement that preserves the order defined above, and then order by that. - - # This is a bit dense, but the code below creates a Case() with a list comprehension that - # creates a list of When's that look like this: - # ORDER BY - # CASE - # WHEN id=10 THEN 0 - # WHEN id=45 THEN 1 - # WHEN id=60 THEN 2 - # END; - # More below: - # https://stackoverflow.com/questions/4916851/django-get-a-queryset-from-array-of-ids-in-specific-order - - preserved_order = Case( - *[When(pk=pk, then=pos) for pos, pk in enumerate(ordered_profile_ids)] - ) - - # Finally, we can now create a new deduped Queryset, with the correct ordering, and prefetch - # the related tags and user objects, to avoid expensive N+1 queries later on. Phew! - ordered_deduped_profiles = ( - Profile.objects.filter(id__in=ordered_profile_ids) - .order_by(preserved_order) - .prefetch_related("tags", "user") - ) - - pager = paginator.Paginator(ordered_deduped_profiles, NO_PROFILES_PER_PAGE) - page = request.GET.get("page", 1) - - try: - ctx["paginated_profiles"] = pager.page(page) - except paginator.PageNotAnInteger: - ctx["paginated_profiles"] = pager.page(1) - except paginator.EmptyPage: - ctx["paginated_profiles"] = pager.page(paginator.num_pages) - - active_tag_ids = request.GET.getlist("tags") - - if active_tag_ids: - from ..models import flat_tag_list - - ctx["active_tags"] = flat_tag_list(Tag.objects.filter(id__in=active_tag_ids)) - - return ctx - - -def has_active_search(request: HttpRequest, context: dict): - """ - Check if the request has any active search terms - """ - search = request.GET.get("bio", None) - tags = request.GET.get("tags", None) - profile = context.get("profile") - - return bool(tags or search or profile) - - -def hide_profile_list(request: HttpRequest, context: dict): - """ - A helper function to determine whether to hide the profile list on mobile sites. - TODO: this might make more sense as a template tag. We should decide whether - to move into a template tag instead. - """ - search = request.GET.get("bio", None) - tags = request.GET.get("tags", None) - active_search = bool(tags or search) - profile = context.get("profile") - - # if we have a profile, and active search - we show the profile slot, but hide the list - if profile: - return True - - # otherwise if we have no profile, but an active search, we show - # the list to click through to - if active_search: - return False - - # if we have no profile and no active search -- we show the profile slot, - # hiding sidebar on mobile (our profile slot has the instructions) - return True - - -@login_required -def homepage(request): - ctx = {"is_authenticated": request.user.is_authenticated} - - ctx = fetch_profile_list(request, ctx) - - should_hide_profile_list = hide_profile_list(request, ctx) - - ctx["hide_profile_list"] = should_hide_profile_list - logger.warn(f"should_hide_profile_list: {should_hide_profile_list}") - if request.htmx: - template_name = "pages/_home_partial.html" - - response = render(request, template_name, ctx) - rendered_active_tags = render_to_string("_active_tags_list.html", ctx, request) - - # passing this triggers an update of the rendering for touch devices, - # to switch between showing a profile or a profile list - - logger.info(f"should_hide_profile_list: {should_hide_profile_list}") - response = trigger_client_event( - response, - "update-profile", - {"hide_profile_list": should_hide_profile_list}, - ) - - return trigger_client_event( - response, - "active-tags-changed", - {"rendered_html": rendered_active_tags}, - ) - - else: - template_name = "pages/home.html" - - return render(request, template_name, ctx) - - -class ProfileDetailView(LoginRequiredMixin, DetailView): - """ - A template view that exposes information about the - user being logged in - """ - - queryset = Profile.objects.all() - slug_field = "short_id" - template_name = "pages/home.html" - - def get_context_data(self, **kwargs): - """ """ - is_authenticated = self.request.user.is_authenticated - - ctx = { - "is_authenticated": is_authenticated, - } - - ctx = fetch_profile_list(self.request, ctx) - - if self.object is not None: - ctx["profile"] = self.object - active_tag_ids = self.request.GET.getlist("tags") - - md = MarkdownIt() - if self.object.bio: - markdown_bio = md.render(self.object.bio) - ctx["profile_rendered_bio"] = markdown_bio - - grouped_tags, ungrouped_tags = self.object.tags_by_grouping() - - ctx["grouped_tags"] = grouped_tags - ctx["ungrouped_tags"] = ungrouped_tags - ctx["active_tag_ids"] = [int(tag_id) for tag_id in active_tag_ids] - - if ( - self.object.user == self.request.user - or self.request.user.is_superuser - or self.request.user.is_staff - ): - ctx["can_edit"] = True - - return ctx - - def get(self, request, *args, **kwargs): - self.object = self.get_object() - context = self.get_context_data(object=self.object) - - if request.htmx: - self.template_name = "_profile.html" - response = self.render_to_response(context) - - rendered_active_tags = render_to_string( - "_active_tags_list.html", context, request - ) - should_hide_profile = hide_profile_list(request, context) - response = trigger_client_event( - response, - "update-profile", - {"hide_profile_list": should_hide_profile}, - ) - return trigger_client_event( - response, "active-tags-changed", {"rendered_html": rendered_active_tags} - ) - - return self.render_to_response(context) - - -class ProfileEditView(LoginRequiredMixin, PermissionRequiredMixin, UpdateView): - queryset = Profile.objects.all() - slug_field = "short_id" - template_name = "pages/edit_profile.html" - form_class = ProfileUpdateForm - - def has_permission(self): - """ - Users should only be able to edit their own profiles. - Admins can edit any profile. - """ - - if self.request.user == self.get_object().user: - return True - - if self.request.user.is_superuser or self.request.user.is_staff: - return True - - return False - - def form_valid(self, form): - """ - If the form is valid, save the associated model. - """ - self.object = form.save() - - if "photo" in form.cleaned_data: - form.instance.update_thumbnail_urls() - return super().form_valid(form) - - def get_form_kwargs(self): - """ - Return the keyword arguments for instantiating the form. - """ - kwargs = super().get_form_kwargs() - if hasattr(self, "object"): - kwargs.update({"instance": self.object}) - kwargs["initial"] = { - "name": self.object.user.name, - "email": self.object.user.email, - } - return kwargs - - -class ProfileCreateView(CreateView): - template_name = "pages/create_profile.html" - form_class = ProfileCreateForm - model = Profile - - def has_permission(self): - """ - Users should only be able to edit their own profiles. - Admins can edit any profile. - """ - - if self.request.user == self.get_object().user: - return True - - if self.request.user.is_superuser or self.request.user.is_staff: - return True - - return False - - def form_valid(self, form): - """ - If the form is valid, save the associated model. - """ - return super().form_valid(form) - - -class ProfileViewSet( - RetrieveModelMixin, - ListModelMixin, - UpdateModelMixin, - CreateModelMixin, - GenericViewSet, -): - serializer_class = ProfileSerializer - queryset = ( - Profile.objects.filter(visible=True) - .prefetch_related("tags") - .prefetch_related("clusters") - .select_related("user") - ) - lookup_field = "id" - - @action(detail=True, methods=["POST"]) - def resend_invite(self, request, id=None): - assert id - profile = Profile.objects.get(pk=id) - try: - profile.send_invite_mail() - - return Response( - status=status.HTTP_200_OK, - data={ - "message": f"An email invite has been re-sent to {profile.email}" - }, - ) - except Exception as exc: - logger.error(exc) - return Response( - status=status.HTTP_500_INTERNAL_SERVER_ERROR, - data={ - "message": ( - "Sorry, we had a problem re-sending the invite email. " - "Please try again later." - ) - }, - ) - - @action(detail=False, methods=["GET"]) - def me(self, request): - serialized_profile = ProfileSerializer(request.user.profile) - return Response(status=status.HTTP_200_OK, data=serialized_profile.data) - - def create(self, request): - """ - Create a profile for the given user, adding them to - the correct admin group, and sending an optional invite - """ - - send_invite = request.data.get("sendInvite") - - serialized_profile = ProfileSerializer(data=request.data) - serialized_profile.is_valid(raise_exception=True) - new_profile = serialized_profile.create(serialized_profile.data) - - full_serialized_profile = ProfileSerializer(new_profile) - - if new_profile.user.is_staff: - mod_group_name = settings.MODERATOR_GROUP_NAME - moderators = Group.objects.get(name=mod_group_name) - new_profile.user.groups.add(moderators) - new_profile.user.save() - new_profile.save() - if send_invite: - new_profile.send_invite_mail() - - headers = self.get_success_headers(full_serialized_profile.data) - return Response( - full_serialized_profile.data, - status=status.HTTP_201_CREATED, - headers=headers, - ) - - def get_object(self): - """ - Override the standard request to allow a user to see - their own profile, even when it's hidden. - """ - - # First the boiler plate - queryset = self.filter_queryset(self.get_queryset()) - - # Perform the lookup filtering. - lookup_url_kwarg = self.lookup_url_kwarg or self.lookup_field - - assert lookup_url_kwarg in self.kwargs, ( - "Expected view %s to be called with a URL keyword argument " - 'named "%s". Fix your URL conf, or set the `.lookup_field` ' - "attribute on the view correctly." - % (self.__class__.__name__, lookup_url_kwarg) - ) - - filter_kwargs = {self.lookup_field: self.kwargs[lookup_url_kwarg]} - - # now our check for when a user is hidden but also logged in - current_user = self.request.user - - if current_user.profile.id == int(filter_kwargs.get("id")): - if current_user.has_profile(): - return current_user.profile - - # otherwise do the usual - obj = get_object_or_404(queryset, **filter_kwargs) - - # May raise a permission denied - self.check_object_permissions(self.request, obj) - - return obj - - def update(self, request, *args, **kwargs): - partial = kwargs.pop("partial", False) - - inbound_data = request.data.copy() - - profile_id = resolve(request.path).kwargs["id"] - instance = Profile.objects.get(id=profile_id) - - serialized_profile = self.serializer_class( - instance, data=inbound_data, partial=partial - ) - serialized_profile.is_valid(raise_exception=True) - serialized_profile.update(instance, serialized_profile.validated_data) - - if getattr(instance, "_prefetched_objects_cache", None): - # If 'prefetch_related' has been applied to a queryset, we need to - # forcibly invalidate the prefetch cache on the instance. - instance._prefetched_objects_cache = {} - return Response(serialized_profile.data) - - -class ProfilePhotoUploadView(APIView): - """ """ - - parser_classes = (MultiPartParser, FormParser) - - def put(self, request, id, format=None): - serializer = ProfilePicSerializer(data=request.data) - serializer.is_valid(raise_exception=True) - - profile = Profile.objects.get(pk=serializer.validated_data["id"]) - profile_pic = serializer.validated_data.pop("photo", None) - - if profile_pic: - img = ImageFile(profile_pic) - photo_path = f"{slugify(profile.name)}.png" - profile.photo.save(photo_path, img, save=True) - profile.update_thumbnail_urls() - - return Response(ProfileSerializer(profile).data) - - -class ClusterViewSet( - # RetrieveModelMixin, - ListModelMixin, - GenericViewSet, -): - serializer_class = ClusterSerializer - queryset = Cluster.objects.all() - - -class TagViewSet( - # RetrieveModelMixin, - ListModelMixin, - GenericViewSet, -): - serializer_class = TagSerializer - queryset = Tag.objects.all() - - -class TagAutoCompleteView(autocomplete.Select2QuerySetView): - def get_queryset(self): - if not self.request.user.is_authenticated: - return Tag.objects.none() - - qs = Tag.objects.all() - - if self.q: - qs = qs.filter(name__icontains=self.q) - - return qs +import logging +from django_htmx.http import trigger_client_event +from django.template.loader import render_to_string +from dal import autocomplete +from django.conf import settings +from django.contrib.auth import get_user_model +from django.contrib.auth.decorators import login_required +from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin +from django.contrib.auth.models import Group +from django.core import paginator +from django.core.files.images import ImageFile +from django.db.models import Case, When +from django.http import HttpRequest +from django.shortcuts import render +from django.urls import resolve, reverse +from django.utils.text import slugify +from django.views.generic import DetailView, UpdateView +from django.views.generic.edit import CreateView +from django.core.mail import send_mail +from django.contrib.sites.shortcuts import get_current_site +from allauth.account.adapter import DefaultAccountAdapter +from django.template import Template, Context +from django.utils.translation import gettext as _ +from allauth.account.utils import filter_users_by_email + +from markdown_it import MarkdownIt +from rest_framework import status +from rest_framework.decorators import action +from rest_framework.generics import get_object_or_404 +from rest_framework.mixins import ( + CreateModelMixin, + ListModelMixin, + RetrieveModelMixin, + UpdateModelMixin, +) + +from rest_framework.parsers import FormParser, MultiPartParser +from rest_framework.response import Response +from rest_framework.views import APIView +from rest_framework.viewsets import GenericViewSet +from taggit.models import Tag + +from ..filters import ProfileFilter +from ..forms import ProfileUpdateForm, ProfileCreateForm +from ..models import Cluster, Profile, Constellation, PasswordResetEmailContent +from .serializers import ( + ClusterSerializer, + ProfilePicSerializer, + ProfileSerializer, + TagSerializer, + +) + +User = get_user_model() + + +logger = logging.getLogger(__name__) +logger.setLevel(logging.DEBUG) +NO_PROFILES_PER_PAGE = 100 + + +def fetch_profile_list(request: HttpRequest, ctx: dict): + """ + Fetch the list of profiles for the given set of params, and + populate the provided context dictionary + """ + filtered_profiles = ProfileFilter( + request.GET, + queryset=Profile.objects.filter(visible=True).prefetch_related("tags", "user"), + ) + + ctx["profile_filter"] = filtered_profiles + + # because we're using full text search with postgres, we need to de-dupe the results + # while preserving their order. We'd normally do this using a distinct() call, but + # because we have multiple instances of the same profile in the queryset, with different + # 'rank' scores. The ORM with Postgres does not let us order by 'rank' if we don't include + # it as a field we are calling distinct on, and doing that would stop us being able to dedupe + # the results. + # So instead we need to manually dedupe the results by id, and then order by that. + ordered_profile_ids = [] + for prof in filtered_profiles.qs: + if prof.id not in ordered_profile_ids: + ordered_profile_ids.append(prof.id) + # once we have a deduped list of ids, we need to convert it back into a queryset, + # so code that expects a queryset can still work. + # Querysets do not guarantee order so for Postgres we need to Case() to create a + # SQL statement that preserves the order defined above, and then order by that. + + # This is a bit dense, but the code below creates a Case() with a list comprehension that + # creates a list of When's that look like this: + # ORDER BY + # CASE + # WHEN id=10 THEN 0 + # WHEN id=45 THEN 1 + # WHEN id=60 THEN 2 + # END; + # More below: + # https://stackoverflow.com/questions/4916851/django-get-a-queryset-from-array-of-ids-in-specific-order + + preserved_order = Case( + *[When(pk=pk, then=pos) for pos, pk in enumerate(ordered_profile_ids)] + ) + + # Finally, we can now create a new deduped Queryset, with the correct ordering, and prefetch + # the related tags and user objects, to avoid expensive N+1 queries later on. Phew! + ordered_deduped_profiles = ( + Profile.objects.filter(id__in=ordered_profile_ids) + .order_by(preserved_order) + .prefetch_related("tags", "user") + ) + + pager = paginator.Paginator(ordered_deduped_profiles, NO_PROFILES_PER_PAGE) + page = request.GET.get("page", 1) + + try: + ctx["paginated_profiles"] = pager.page(page) + except paginator.PageNotAnInteger: + ctx["paginated_profiles"] = pager.page(1) + except paginator.EmptyPage: + ctx["paginated_profiles"] = pager.page(paginator.num_pages) + + active_tag_ids = request.GET.getlist("tags") + + if active_tag_ids: + from ..models import flat_tag_list + + ctx["active_tags"] = flat_tag_list(Tag.objects.filter(id__in=active_tag_ids)) + + return ctx + + +def has_active_search(request: HttpRequest, context: dict): + """ + Check if the request has any active search terms + """ + search = request.GET.get("bio", None) + tags = request.GET.get("tags", None) + profile = context.get("profile") + + return bool(tags or search or profile) + + +def hide_profile_list(request: HttpRequest, context: dict): + """ + A helper function to determine whether to hide the profile list on mobile sites. + TODO: this might make more sense as a template tag. We should decide whether + to move into a template tag instead. + """ + search = request.GET.get("bio", None) + tags = request.GET.get("tags", None) + active_search = bool(tags or search) + profile = context.get("profile") + + # if we have a profile, and active search - we show the profile slot, but hide the list + if profile: + return True + + # otherwise if we have no profile, but an active search, we show + # the list to click through to + if active_search: + return False + # if we have no profile and no active search -- we show the profile slot, + # hiding sidebar on mobile (our profile slot has the instructions) + return False + + +@login_required +def homepage(request): + ctx = {"is_authenticated": request.user.is_authenticated} + + ctx = fetch_profile_list(request, ctx) + + should_hide_profile_list = hide_profile_list(request, ctx) + + ctx["hide_profile_list"] = should_hide_profile_list + logger.warn(f"should_hide_profile_list: {should_hide_profile_list}") + if request.htmx: + template_name = "pages/_home_partial.html" + + response = render(request, template_name, ctx) + rendered_active_tags = render_to_string("_active_tags_list.html", ctx, request) + + # passing this triggers an update of the rendering for touch devices, + # to switch between showing a profile or a profile list + + logger.info(f"should_hide_profile_list: {should_hide_profile_list}") + response = trigger_client_event( + response, + "update-profile", + {"hide_profile_list": should_hide_profile_list}, + ) + + return trigger_client_event( + response, + "active-tags-changed", + {"rendered_html": rendered_active_tags}, + ) + + else: + template_name = "pages/home.html" + + return render(request, template_name, ctx) + + +class ProfileDetailView(LoginRequiredMixin, DetailView): + """ + A template view that exposes information about the + user being logged in + """ + + queryset = Profile.objects.all() + slug_field = "short_id" + template_name = "pages/home.html" + + def get_context_data(self, **kwargs): + """ """ + is_authenticated = self.request.user.is_authenticated + + ctx = { + "is_authenticated": is_authenticated, + } + + ctx = fetch_profile_list(self.request, ctx) + + if self.object is not None: + print("aya aya ya") + ctx["profile"] = self.object + active_tag_ids = self.request.GET.getlist("tags") + + md = MarkdownIt() + if self.object.bio: + markdown_bio = md.render(self.object.bio) + ctx["profile_rendered_bio"] = markdown_bio + + grouped_tags, ungrouped_tags = self.object.tags_by_grouping() + + ctx["grouped_tags"] = grouped_tags + ctx["ungrouped_tags"] = ungrouped_tags + ctx["active_tag_ids"] = [int(tag_id) for tag_id in active_tag_ids] + + if ( + self.object.user == self.request.user + or self.request.user.is_superuser + or self.request.user.is_staff + ): + ctx["can_edit"] = True + + should_hide_profile_list = hide_profile_list(self.request, ctx) + ctx["hide_profile_list"] = should_hide_profile_list + + return ctx + + def get(self, request, *args, **kwargs): + self.object = self.get_object() + context = self.get_context_data(object=self.object) + + if request.htmx: + self.template_name = "_profile.html" + response = self.render_to_response(context) + + rendered_active_tags = render_to_string( + "_active_tags_list.html", context, request + ) + should_hide_profile = hide_profile_list(request, context) + response = trigger_client_event( + response, + "update-profile", + {"hide_profile_list": should_hide_profile}, + ) + return trigger_client_event( + response, "active-tags-changed", {"rendered_html": rendered_active_tags} + ) + + return self.render_to_response(context) + + +class ProfileEditView(LoginRequiredMixin, PermissionRequiredMixin, UpdateView): + queryset = Profile.objects.all() + slug_field = "short_id" + template_name = "pages/edit_profile.html" + form_class = ProfileUpdateForm + + def has_permission(self): + """ + Users should only be able to edit their own profiles. + Admins can edit any profile. + """ + + if self.request.user == self.get_object().user: + return True + + if self.request.user.is_superuser or self.request.user.is_staff: + return True + + return False + + def form_valid(self, form): + """ + If the form is valid, save the associated model. + """ + self.object = form.save() + + if "photo" in form.cleaned_data: + form.instance.update_thumbnail_urls() + return super().form_valid(form) + + def get_form_kwargs(self): + """ + Return the keyword arguments for instantiating the form. + """ + kwargs = super().get_form_kwargs() + if hasattr(self, "object"): + kwargs.update({"instance": self.object}) + kwargs["initial"] = { + "name": self.object.user.name, + "email": self.object.user.email, + } + return kwargs + + def get_context_data(self, **kwargs): + """ + Add extra context to the template. + """ + # Fetch the default context from the superclass + context = super().get_context_data(**kwargs) + + # Fetch additional context (e.g., profile list) + context = fetch_profile_list(self.request, context) + + return context + + +class ProfileCreateView(CreateView): + template_name = "pages/create_profile.html" + form_class = ProfileCreateForm + model = Profile + + def has_permission(self): + """ + Users should only be able to edit their own profiles. + Admins can edit any profile. + """ + + if self.request.user == self.get_object().user: + return True + + if self.request.user.is_superuser or self.request.user.is_staff: + return True + + return False + + def form_valid(self, form): + """ + If the form is valid, save the associated model. + """ + return super().form_valid(form) + + def get_context_data(self, **kwargs): + """ + Add extra context to the template. + """ + # Fetch the default context from the superclass + context = super().get_context_data(**kwargs) + + # Fetch additional context (e.g., profile list) + context = fetch_profile_list(self.request, context) + + return context + + +class ProfileViewSet( + RetrieveModelMixin, + ListModelMixin, + UpdateModelMixin, + CreateModelMixin, + GenericViewSet, +): + serializer_class = ProfileSerializer + queryset = ( + Profile.objects.filter(visible=True) + .prefetch_related("tags") + .prefetch_related("clusters") + .select_related("user") + ) + lookup_field = "id" + + @action(detail=True, methods=["POST"]) + def resend_invite(self, request, id=None): + assert id + profile = Profile.objects.get(pk=id) + try: + profile.send_invite_mail() + + return Response( + status=status.HTTP_200_OK, + data={ + "message": f"An email invite has been re-sent to {profile.email}" + }, + ) + except Exception as exc: + logger.error(exc) + return Response( + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + data={ + "message": ( + "Sorry, we had a problem re-sending the invite email. " + "Please try again later." + ) + }, + ) + + @action(detail=False, methods=["GET"]) + def me(self, request): + serialized_profile = ProfileSerializer(request.user.profile) + return Response(status=status.HTTP_200_OK, data=serialized_profile.data) + + def create(self, request): + """ + Create a profile for the given user, adding them to + the correct admin group, and sending an optional invite + """ + + send_invite = request.data.get("sendInvite") + + serialized_profile = ProfileSerializer(data=request.data) + serialized_profile.is_valid(raise_exception=True) + new_profile = serialized_profile.create(serialized_profile.data) + + full_serialized_profile = ProfileSerializer(new_profile) + + if new_profile.user.is_staff: + mod_group_name = settings.MODERATOR_GROUP_NAME + moderators = Group.objects.get(name=mod_group_name) + new_profile.user.groups.add(moderators) + new_profile.user.save() + new_profile.save() + if send_invite: + new_profile.send_invite_mail() + + headers = self.get_success_headers(full_serialized_profile.data) + return Response( + full_serialized_profile.data, + status=status.HTTP_201_CREATED, + headers=headers, + ) + + def get_object(self): + """ + Override the standard request to allow a user to see + their own profile, even when it's hidden. + """ + + # First the boiler plate + queryset = self.filter_queryset(self.get_queryset()) + + # Perform the lookup filtering. + lookup_url_kwarg = self.lookup_url_kwarg or self.lookup_field + + assert lookup_url_kwarg in self.kwargs, ( + "Expected view %s to be called with a URL keyword argument " + 'named "%s". Fix your URL conf, or set the `.lookup_field` ' + "attribute on the view correctly." + % (self.__class__.__name__, lookup_url_kwarg) + ) + + filter_kwargs = {self.lookup_field: self.kwargs[lookup_url_kwarg]} + + # now our check for when a user is hidden but also logged in + current_user = self.request.user + + if current_user.profile.id == int(filter_kwargs.get("id")): + if current_user.has_profile(): + return current_user.profile + + # otherwise do the usual + obj = get_object_or_404(queryset, **filter_kwargs) + + # May raise a permission denied + self.check_object_permissions(self.request, obj) + + return obj + + def update(self, request, *args, **kwargs): + partial = kwargs.pop("partial", False) + + inbound_data = request.data.copy() + + profile_id = resolve(request.path).kwargs["id"] + instance = Profile.objects.get(id=profile_id) + + serialized_profile = self.serializer_class( + instance, data=inbound_data, partial=partial + ) + serialized_profile.is_valid(raise_exception=True) + serialized_profile.update(instance, serialized_profile.validated_data) + + if getattr(instance, "_prefetched_objects_cache", None): + # If 'prefetch_related' has been applied to a queryset, we need to + # forcibly invalidate the prefetch cache on the instance. + instance._prefetched_objects_cache = {} + return Response(serialized_profile.data) + + +class ProfilePhotoUploadView(APIView): + """ """ + + parser_classes = (MultiPartParser, FormParser) + + def put(self, request, id, format=None): + serializer = ProfilePicSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + profile = Profile.objects.get(pk=serializer.validated_data["id"]) + profile_pic = serializer.validated_data.pop("photo", None) + + if profile_pic: + img = ImageFile(profile_pic) + photo_path = f"{slugify(profile.name)}.png" + profile.photo.save(photo_path, img, save=True) + profile.update_thumbnail_urls() + + return Response(ProfileSerializer(profile).data) + + +class ClusterViewSet( + # RetrieveModelMixin, + ListModelMixin, + GenericViewSet, +): + serializer_class = ClusterSerializer + queryset = Cluster.objects.all() + + +class TagViewSet( + # RetrieveModelMixin, + ListModelMixin, + GenericViewSet, +): + serializer_class = TagSerializer + queryset = Tag.objects.all() + + +class TagAutoCompleteView(autocomplete.Select2QuerySetView): + def get_queryset(self): + if not self.request.user.is_authenticated: + return Tag.objects.none() + + qs = Tag.objects.all() + + if self.q: + qs = qs.filter(name__icontains=self.q) + + return qs + + +class CustomAccountAdapter(DefaultAccountAdapter): + """ + CustomAccountAdapter class for handling email sending during account actions. + + This class overrides the default behavior of sending emails in Django + allauth, allowing for custom templates and messages based on the current site. + """ + + def send_mail(self, template_prefix, email, context): + users = filter_users_by_email(email) + user = users[0] if users else None + context['user'] = user + + # Check if the user exists + if user: + context['password_reset_url'] = self.get_password_reset_url(context) + # User exists, get their profile + context['profile'] = Profile.objects.get(user=user) + email_content = self.get_email_content(context) + + # Send the email to the user + send_mail( + f"Welcome to {context['constellation']}", + email_content, + None, + [email], + html_message=email_content, + ) + else: + # User does not exist, send default email + self.send_default_email(email) + + def send_default_email(self, email): + current_site = get_current_site(self.request) + subject = "Hello from Constellate!" + message = f""" + You are receiving this email because you, or someone else, tried to access an account with email {email}. + However, we do not have any record of such an account in our database. + + This mail can be safely ignored if you did not initiate this action. + + If it was you, you can sign up for an account using the link below. + + https://{current_site.domain}/accounts/signup/ + + Thank you for using Constellate! + """ + + # Send the default email + send_mail( + subject, + message, + None, + [email], + ) + + def get_email_content(self, context): + current_site = get_current_site(self.request) + + # Try to get the custom email template from the database + email_confirmation = PasswordResetEmailContent.objects.filter(site=current_site).first() + + if email_confirmation: + # Create a Django Template object from the email content + email_content_template = Template(email_confirmation.email_content) + context["constellation"] = current_site.name # Set site name + context["reset_link"] = self.get_password_reset_url(context) # Set password reset link + + # Render the template with the context + context["password_reset_content"] = email_content_template.render(Context(context)) + else: + # Default password reset message template + default_message_template = Template( + '''

Hello {{ profile.name }},

+

You requested a password reset for your account on {{ constellation }}.

+

Click the link below to reset your password:

+

Reset Password

+

If you did not request this email, please ignore it.

+

Thank you!

''' + ) + + # Set the necessary context variables for the default message + context["constellation"] = current_site.name # Set site name + context["reset_link"] = self.get_password_reset_url(context) # Set password reset link + + # Render the default template with the context + context["password_reset_content"] = default_message_template.render(Context(context)) + + print(context["password_reset_content"], "Rendered email content") # Debug output + return context["password_reset_content"] # Return the rendered HTML safely + + def get_password_reset_url(self, context): + return context['password_reset_url'] \ No newline at end of file diff --git a/cl8/users/filters.py b/cl8/users/filters.py index 5fd1f39..7519413 100644 --- a/cl8/users/filters.py +++ b/cl8/users/filters.py @@ -1,64 +1,64 @@ -import django_filters -from cl8.users import models as cl8_models -from taggit import models as taggit_models -from dal import autocomplete - -from django.contrib.postgres.search import SearchVector, SearchQuery, SearchRank - - -class ProfileFilter(django_filters.FilterSet): - bio = django_filters.CharFilter(method="search_fulltext") - - # We now hide the select2 widget in the profile filter component shown to users - # but keeping this helps for debugging tag filtering issues - tags = django_filters.ModelMultipleChoiceFilter( - field_name="tags", - label="Tags", - queryset=taggit_models.Tag.objects.all(), - # comment these out, and remove the hidden class - # on the profile filter template to use the select2 autocomplete - # widget - # widget=autocomplete.ModelSelect2Multiple(url="tag-autocomplete"), - # conjoined means that all tags must be present, not just any of the tags - conjoined=True, - ) - - def search_fulltext(self, queryset, field_name, value): - """ - Override the default search behaviour to use Postgres full text search, to search - a number of fields simultaneously, returning a ranked listing of results - """ - # https://github.com/carltongibson/django-filter/issues/1039 - # https://stackoverflow.com/questions/76397037/django-full-text-search-taggit - - search_query = SearchQuery(value, search_type="websearch") - search_vector = SearchVector( - # we want the user's name to be searchable - "user__name", - # along with all the text of their bio - "user__email", - "bio", - # and location - "location", - # and the text of all the tags associated with the profile - "tags__name", - # and any social media handles - "twitter", - "linkedin", - "facebook", - "website", - "organisation", - ) - - return ( - # this ranks our results by how well they match the search query - # annotating each result with a score in the property 'rank' from 0 to 1 - queryset.annotate(rank=SearchRank(search_vector, search_query)) - .filter(rank__gt=0) - .distinct() - .order_by("-rank") - ) - - class Meta: - model = cl8_models.Profile - fields = ["bio"] +import django_filters +from cl8.users import models as cl8_models +from taggit import models as taggit_models +from dal import autocomplete + +from django.contrib.postgres.search import SearchVector, SearchQuery, SearchRank + + +class ProfileFilter(django_filters.FilterSet): + bio = django_filters.CharFilter(method="search_fulltext") + + # We now hide the select2 widget in the profile filter component shown to users + # but keeping this helps for debugging tag filtering issues + tags = django_filters.ModelMultipleChoiceFilter( + field_name="tags", + label="Tags", + queryset=taggit_models.Tag.objects.all(), + # comment these out, and remove the hidden class + # on the profile filter template to use the select2 autocomplete + # widget + # widget=autocomplete.ModelSelect2Multiple(url="tag-autocomplete"), + # conjoined means that all tags must be present, not just any of the tags + conjoined=True, + ) + + def search_fulltext(self, queryset, field_name, value): + """ + Override the default search behaviour to use Postgres full text search, to search + a number of fields simultaneously, returning a ranked listing of results + """ + # https://github.com/carltongibson/django-filter/issues/1039 + # https://stackoverflow.com/questions/76397037/django-full-text-search-taggit + + search_query = SearchQuery(value, search_type="websearch") + search_vector = SearchVector( + # we want the user's name to be searchable + "user__name", + # along with all the text of their bio + "user__email", + "bio", + # and location + "location", + # and the text of all the tags associated with the profile + "tags__name", + # and any social media handles + "website", + "social_1", + "social_2", + "social_3", + "organisation", + ) + + return ( + # this ranks our results by how well they match the search query + # annotating each result with a score in the property 'rank' from 0 to 1 + queryset.annotate(rank=SearchRank(search_vector, search_query)) + .filter(rank__gt=0) + .distinct() + .order_by("-rank") + ) + + class Meta: + model = cl8_models.Profile + fields = ["bio"] diff --git a/cl8/users/forms.py b/cl8/users/forms.py index 2a29a7f..8551675 100644 --- a/cl8/users/forms.py +++ b/cl8/users/forms.py @@ -1,137 +1,186 @@ -from dal import autocomplete -from django import forms -from django.contrib.auth import forms as auth_forms -from django.contrib.auth import get_user_model -from django.contrib.auth.models import Group -from django.core.exceptions import ValidationError -from django.forms import ModelMultipleChoiceField -from django.utils.translation import gettext_lazy as _ -from taggit import models as taggit_models - -from .importers import safe_username -from .models import Profile - -User = get_user_model() - - -class UserChangeForm(auth_forms.UserChangeForm): - class Meta(auth_forms.UserChangeForm.Meta): - model = User - - -class UserCreationForm(auth_forms.UserCreationForm): - error_message = auth_forms.UserCreationForm.error_messages.update( - {"duplicate_username": _("This username has already been taken.")} - ) - - class Meta(auth_forms.UserCreationForm.Meta): - model = User - - def clean_username(self): - username = self.cleaned_data["username"] - - try: - User.objects.get(username=username) - except User.DoesNotExist: - return username - - raise ValidationError(self.error_messages["duplicate_username"]) - - -class ProfileUpdateForm(forms.ModelForm): - name = forms.CharField() - # email = forms.EmailField() - - tags = ModelMultipleChoiceField( - queryset=taggit_models.Tag.objects.all(), - widget=autocomplete.ModelSelect2Multiple(url="tag-autocomplete-with-create"), - required=False, - ) - - def save(self, commit=True): - """ """ - self.instance.user.name = self.cleaned_data.get("name") - self.instance.user.save() - - return super().save(commit=commit) - - class Meta: - model = Profile - fields = [ - "photo", - "name", - "phone", - "location", - "organisation", - "bio", - "tags", - "twitter", - "linkedin", - "facebook", - "visible", - ] - - -class ProfileCreateForm(forms.ModelForm): - name = forms.CharField(required=True) - email = forms.EmailField(required=True) - - tags = ModelMultipleChoiceField( - queryset=taggit_models.Tag.objects.all(), - widget=autocomplete.ModelSelect2Multiple(url="tag-autocomplete-with-create"), - ) - - def save(self, commit=True): - """ - Create a user, then save the corresponding profile object - """ - - email = self.cleaned_data.get("email") - user, created = User.objects.get_or_create( - email=email, - ) - user.username = safe_username() - user.name = self.cleaned_data.get("name") - user.save() - profile = Profile.objects.create(user=user) - - profile.phone = self.cleaned_data.get("phone") - profile.website = self.cleaned_data.get("website") - profile.twitter = self.cleaned_data.get("twitter") - profile.facebook = self.cleaned_data.get("facebook") - profile.linkedin = self.cleaned_data.get("linkedin") - profile.bio = self.cleaned_data.get("bio") - profile.visible = self.cleaned_data.get("visible") - profile.short_id = safe_username() - profile.import_id = "profile-form" - profile.photo = self.cleaned_data.get("photo") - - profile.save() - profile.update_thumbnail_urls() - - self.instance = profile - result = super().save(commit=commit) - - # add the user to the 'member' group - member_group = Group.objects.get(name="member") - member_group.user_set.add(user) - member_group.save() - - return result - - class Meta: - model = Profile - fields = [ - "photo", - "name", - "email", - "location", - "organisation", - "bio", - "phone", - "tags", - "twitter", - "linkedin", - "facebook", - "visible", - ] +from dal import autocomplete +from django import forms +from django.contrib.auth import forms as auth_forms +from django.core.validators import URLValidator +from django.contrib.auth import get_user_model +from django.contrib.auth.models import Group +from django.core.exceptions import ValidationError +from django.forms import ModelMultipleChoiceField +from django.utils.translation import gettext_lazy as _ +from taggit import models as taggit_models + +from .importers import safe_username +from .models import Profile, User + +User = get_user_model() + + +class UserChangeForm(auth_forms.UserChangeForm): + class Meta(auth_forms.UserChangeForm.Meta): + model = User + + +class UserCreationForm(auth_forms.UserCreationForm): + error_message = auth_forms.UserCreationForm.error_messages.update( + {"duplicate_username": _("This username has already been taken.")} + ) + + class Meta(auth_forms.UserCreationForm.Meta): + model = User + + def clean_username(self): + username = self.cleaned_data["username"] + + try: + User.objects.get(username=username) + except User.DoesNotExist: + return username + + raise ValidationError(self.error_messages["duplicate_username"]) + + +class ProfileUpdateForm(forms.ModelForm): + name = forms.CharField() + email = forms.EmailField() + + tags = ModelMultipleChoiceField( + queryset=taggit_models.Tag.objects.all(), + widget=autocomplete.ModelSelect2Multiple(url="tag-autocomplete-with-create"), + required=False, + ) + + def clean(self): + cleaned_data = super().clean() + url_validator = URLValidator() + social_fields = ["website", "social_1", "social_2", "social_3"] + + # Loop through the social fields and validate each one + for field in social_fields: + social_link = cleaned_data.get(field) + if social_link: # Only validate if the field is not empty + try: + url_validator(social_link) + except ValidationError: + self.add_error(field, "Please enter a valid URL for this social link.") + + return cleaned_data + + def clean_email(self): + email = self.cleaned_data.get("email") + if User.objects.filter(email=email).exclude(pk=self.instance.user.pk).exists(): + raise ValidationError("A user with this email already exists.") + return email + + def save(self, commit=True): + # Update user's name + self.instance.user.name = self.cleaned_data.get("name") + + # Update user's email + self.instance.user.email = self.cleaned_data.get("email") + + # Save user instance + if commit: + self.instance.user.save() + + return super().save(commit=commit) + + class Meta: + model = Profile + fields = [ + "photo", + "email", + "name", + "phone", + "website", + "location", + "organisation", + "bio", + "tags", + "social_1", + "social_2", + "social_3", + "visible", + ] + + +class ProfileCreateForm(forms.ModelForm): + name = forms.CharField(required=True) + email = forms.EmailField(required=True) + + tags = ModelMultipleChoiceField( + queryset=taggit_models.Tag.objects.all(), + required=False, # Set required to False + widget=autocomplete.ModelSelect2Multiple(url="tag-autocomplete-with-create"), + ) + + def clean(self): + cleaned_data = super().clean() + url_validator = URLValidator() + social_fields = ["website", "social_1", "social_2", "social_3"] + + # Loop through the social fields and validate each one + for field in social_fields: + social_link = cleaned_data.get(field) + if social_link: # Only validate if the field is not empty + try: + url_validator(social_link) + except ValidationError: + self.add_error(field, "Please enter a valid URL for this social link.") + + return cleaned_data + + def save(self, commit=True): + """ + Create a user, then save the corresponding profile object + """ + + email = self.cleaned_data.get("email") + user, created = User.objects.get_or_create( + email=email, + ) + user.username = safe_username() + user.name = self.cleaned_data.get("name") + user.save(), + profile, profile_create = Profile.objects.get_or_create(user=user) + + profile.phone = self.cleaned_data.get("phone") + profile.website = self.cleaned_data.get("website") + profile.social_1 = self.cleaned_data.get("social_1") + profile.social_2 = self.cleaned_data.get("social_2") + profile.social_3 = self.cleaned_data.get("social_3") + profile.bio = self.cleaned_data.get("bio") + profile.visible = self.cleaned_data.get("visible") + profile.short_id = safe_username() + profile.import_id = "profile-form" + profile.photo = self.cleaned_data.get("photo") + + profile.save() + profile.update_thumbnail_urls() + + self.instance = profile + result = super().save(commit=commit) + + # add the user to the 'member' group + member_group = Group.objects.get(name="member") + member_group.user_set.add(user) + member_group.save() + + return result + + class Meta: + model = Profile + fields = [ + "photo", + "name", + "email", + "location", + "website", + "organisation", + "bio", + "phone", + "tags", + "social_1", + "social_2", + "social_3", + "visible", + ] diff --git a/cl8/users/middleware.py b/cl8/users/middleware.py index bfa502a..bbdb750 100644 --- a/cl8/users/middleware.py +++ b/cl8/users/middleware.py @@ -1,24 +1,63 @@ -from .models import Constellation - -class ConstellationMiddleware: - def __init__(self, get_response): - self.get_response = get_response - # One-time configuration and initialization. - - def __call__(self, request): - # Code to be executed for each request before - # the view (and later middleware) are called. - - try: - request.constellation = Constellation.objects.get(site=request.site) - except Exception as e: - request.constellation = None - - response = self.get_response(request) - - - - # Code to be executed for each request/response after - # the view is called. - - return response +from .models import Constellation + +class ConstellationMiddleware: + def __init__(self, get_response): + self.get_response = get_response + # One-time configuration and initialization. + + def __call__(self, request): + # Code to be executed for each request before + # the view (and later middleware) are called. + + try: + request.constellation = Constellation.objects.get(site=request.site) + except Exception as e: + request.constellation = None + + response = self.get_response(request) + + + + # Code to be executed for each request/response after + # the view is called. + + return response + + +from django.contrib.sites.shortcuts import get_current_site +from .models import Constellation, SendInviteEmailContent, PasswordResetEmailContent + + +class SiteConfigMiddleware: + """ + Middleware to attach Constellation and SendInviteEmailContent objects + to the request based on the current site. + """ + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + # Retrieve the current site for the request + current_site = get_current_site(request) + + # Attach Constellation object to the request + try: + request.constellation = Constellation.objects.get(site=current_site) + except Constellation.DoesNotExist: + request.constellation = None + + # Attach SendInviteEmailContent object to the request + try: + request.email_confirmation = SendInviteEmailContent.objects.get(site=current_site) + except SendInviteEmailContent.DoesNotExist: + request.email_confirmation = None + + try: + request.password_reset_content = PasswordResetEmailContent.objects.get(site=current_site) + except PasswordResetEmailContent.DoesNotExist: + request.password_reset_content = None + + # Call the next middleware or view + response = self.get_response(request) + + return response \ No newline at end of file diff --git a/cl8/users/models.py b/cl8/users/models.py index ea72b43..be36d60 100644 --- a/cl8/users/models.py +++ b/cl8/users/models.py @@ -1,362 +1,550 @@ -from django.conf import settings -from django.contrib.auth.models import AbstractUser -from django.core.mail import send_mail -from django.db import models -from django.db.models import CharField -from django.template.loader import render_to_string -from django.urls import reverse -from django.utils.translation import gettext_lazy as _ -from django.contrib.sites.models import Site -from sorl.thumbnail import get_thumbnail -from taggit.managers import TaggableManager -from taggit.models import TagBase, TaggedItemBase -from django.contrib.sites.models import Site -from shortuuid.django_fields import ShortUUIDField -import logging - - -logger = logging.getLogger(__name__) - -# this is the string we use to identify an admin group -ADMIN_NAME = "admin" - - -class User(AbstractUser): - # First Name and Last Name do not cover name patterns - # around the globe. - name = CharField(_("Name of User"), blank=True, max_length=255) - - def get_absolute_url(self): - return reverse("users:detail", kwargs={"username": self.username}) - - def has_profile(self) -> bool: - """ - A convenience function to safely check if - a user has a matching profile. - """ - matching_profiles = Profile.objects.filter(user_id=self.id) - if matching_profiles: - return True - - def is_in_group(self, group_name: str) -> bool: - """ """ - if self.groups.filter(name=group_name).exists(): - return True - - return False - - def is_admin(self) -> bool: - """ """ - return self.is_in_group("admin") - - -class Cluster(TagBase): - class Meta: - verbose_name = _("Cluster") - verbose_name_plural = _("Clusters") - - -class TaggedCluster(TaggedItemBase): - content_object = models.ForeignKey("Profile", on_delete=models.CASCADE) - - tag = models.ForeignKey( - Cluster, - on_delete=models.CASCADE, - related_name="%(app_label)s_%(class)s_items", - ) - - -def flat_tag_list(tag_queryset) -> list[dict]: - """ - Return a list of tags, with the name split on a colon to allow for grouping - by the kind of tag listed. - This is called multiple times, so we split on the name in python rather than - going back to the database using further filters / exclude classes, which - can cause N+1 queries - """ - tag_list = [] - - for tag in tag_queryset.all(): - split_name = tag.name.split(":") - if len(split_name) == 1: - # there was no colon to split on use the full tag name - tag_list.append({"name": split_name[0], "tag": tag}) - if len(split_name) > 1: - # we DO have a colon to split on - use the full tag name - # and add the group, so we can use it for show the kinds - # of tags as well - tag_list.append({"group": split_name[0], "name": split_name[1], "tag": tag}) - - return tag_list - - -class Profile(models.Model): - user = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) - phone = models.CharField(_("phone"), max_length=254, blank=True, null=True) - website = models.URLField(_("website"), max_length=200, blank=True, null=True) - organisation = models.CharField( - _("organisation"), max_length=254, blank=True, null=True - ) - twitter = models.CharField(_("twitter"), max_length=254, blank=True, null=True) - facebook = models.CharField(_("facebook"), max_length=254, blank=True, null=True) - linkedin = models.CharField(_("linkedin"), max_length=254, blank=True, null=True) - bio = models.TextField(_("bio"), blank=True, null=True) - visible = models.BooleanField(_("visible"), default=False) - location = models.CharField(_("location"), max_length=254, blank=True, null=True) - photo = models.ImageField( - _("photo"), blank=True, null=True, max_length=200, upload_to="photos" - ) - - tags = TaggableManager(blank=True) - clusters = TaggableManager("Clusters", blank=True, through=TaggedCluster) - - # short_id is a unique identifier for a profile, used in the URL - short_id = ShortUUIDField(length=8, unique=True, blank=True, null=True) - - # for tracking where this profile was imported from - import_id = models.CharField( - _("import_code"), max_length=254, blank=True, null=True - ) - # we have these as cache tables because when we use object storage - # we end up making loads of requests to AWS just to return an url - _photo_url = models.URLField(blank=True, null=True) - _photo_thumbnail_url = models.URLField(blank=True, null=True) - _photo_detail_url = models.URLField(blank=True, null=True) - - class Meta: - verbose_name = _("Profile") - verbose_name_plural = _("Profiles") - ordering = ["user__name"] - permissions = [ - ("set_visibility", "Can set the visibility of a profile"), - ("send_invite_email", "Can send profile invite emails"), - ( - "import_profiles", - "Can import profiles from a CSV file, or from a Firebase export JSON file", - ), - ] - - @property - def name(self): - return self.user.name - - @name.setter - def name(self, value): - self.user.name = value - - @property - def email(self): - return self.user.email - - @property - def admin(self): - return self.user.is_staff - - @property - def thumbnail_photo(self): - if not self.photo: - return None - - if self._photo_thumbnail_url: - return self._photo_thumbnail_url - - return get_thumbnail(self.photo, "100x100", crop="center", quality=99).url - - @property - def detail_photo(self): - """ - A photo, designed for showing on a page, when viewing a profile, - with a user's details - - """ - if not self.photo: - return None - - if self._photo_detail_url: - return self._photo_detail_url - - return get_thumbnail(self.photo, "250x250", crop="center", quality=99).url - - def tags_by_grouping(self): - """ - Return a list of tags, grouped by their type - """ - grouped_tags = {} - ungrouped_tags = [] - # group tags in a dict based on the name of the tag, once it is split at the ":" in the name - for tag in self.tags.filter(name__icontains=":").order_by("name"): - try: - tag_group, tag_name = tag.name.split(":") - tag_name = tag.name.split(":")[1] - if tag_group not in grouped_tags: - grouped_tags[tag_group] = [] - grouped_tags[tag_group].append({"name": tag_name, "tag": tag}) - except ValueError: - logger.warning(f"Unable to split tag name: {tag.name}. Not showing.") - - for ungrouped_tag in self.tags.exclude(name__icontains=":").order_by("name"): - ungrouped_tags.append({"name": ungrouped_tag.name, "tag": ungrouped_tag}) - - return [grouped_tags, ungrouped_tags] - - def tags_with_no_grouping(self): - """ - Return a list of tags, flattened into a single list - """ - return flat_tag_list(self.tags) - # tag_list = [] - # # group tags in a dict based on the name of the tag, once it is split at the ":" in the name - # for tag in self.tags.filter(name__icontains=":"): - # tag_group, tag_name = tag.name.split(":") - # tag_list.append({"name": tag_name, "tag": tag}) - - # for ungrouped_tag in self.tags.exclude(name__icontains=":"): - # tag_list.append({"name": ungrouped_tag.name, "tag": ungrouped_tag}) - - # return tag_list - - def __str__(self): - if self.user.name: - return f"{self.user.name} - {self.import_id}" - else: - return f"{self.user.first_name} - {self.import_id}" - - def get_absolute_url(self): - return reverse("profile-detail", args=[self.short_id]) - - def update_thumbnail_urls(self): - """Generate the thumbnails for a profile""" - if self.photo: - self._photo_url = self.photo.url - self._photo_thumbnail_url = get_thumbnail( - self.photo, "100x100", crop="center", quality=99 - ).url - self._photo_detail_url = get_thumbnail( - self.photo, "250x250", crop="center", quality=99 - ).url - - self.save( - update_fields=[ - "_photo_thumbnail_url", - "_photo_detail_url", - "_photo_url", - ] - ) - - def get_context_for_emails(self) -> dict: - current_site = Site.objects.get_current() - constellation = Constellation.objects.get(site=current_site) - support_email_address = settings.SUPPORT_EMAIL - - return { - "profile": self, - "support_email_address": support_email_address, - "constellation": constellation, - "site": current_site, - } - - def send_invite_mail(self): - context = self.get_context_for_emails() - rendered_templates = self.generate_invite_mail() - - send_mail( - f"Welcome to { context['site'].name }", - rendered_templates["text"], - context.get("support_email_address"), - [self.user.email], - html_message=rendered_templates["html"], - ) - - def generate_invite_mail(self): - context = self.get_context_for_emails() - rendered_invite_txt = render_to_string( - "invite_new_profile.txt", - context, - ) - rendered_invite_html = render_to_string( - "invite_new_profile.mjml.html", - context, - ) - - from cl8.utils.templating import view_rendered_html_in_browser - - view_rendered_html_in_browser(rendered_invite_html) - - return {"text": rendered_invite_txt, "html": rendered_invite_html} - - -class Constellation(models.Model): - """ - A Constellation is a name we give to a specific 'container' for the members - using constellate. Constellations might have a description, some specific welcome text, and logo to members recognise it. - """ - - site = models.OneToOneField( - Site, - on_delete=models.CASCADE, - primary_key=True, - related_name="profiles", - verbose_name="site", - ) - - logo = models.ImageField( - _("logo"), - blank=True, - null=True, - max_length=200, - upload_to="logos", - ) - background_color = models.CharField( - blank=True, - max_length=256, - help_text="A hex code colour to use for the header background colour", - ) - text_color = models.CharField( - blank=True, - max_length=256, - help_text="A hex code colour to use for the header text colour", - ) - favicon = models.ImageField( - _("favicon"), - blank=True, - null=True, - max_length=200, - upload_to="favicons", - ) - - signin_via_slack = models.BooleanField(default=False) - signin_via_email = models.BooleanField(default=True) - - def __str__(self): - return f"{self.site.name}" - - -class CATJoinRequest(models.Model): - joined_at = models.DateTimeField() - email = models.EmailField(unique=False) - city_country = models.CharField(blank=True, max_length=256) - # Why are you interested in joining? - why_join = models.TextField(blank=True) - # What's the main thing you'd like to offer as a part of the community? - main_offer = models.TextField(blank=True) - - def __str__(self): - return f"{self.joined_at.strftime('%Y-%m-%d')} - {self.email}" - - def bio_text_from_join_request(self): - """ - Return a markdown version of the responses given when joining. Used to fill - out a member's profile. - """ - return f""" -### Why are you interested in joining? - -{self.why_join} - - -### What's the main thing you'd like to offer as a part of the community? -{self.main_offer} -""" - - class Meta: - indexes = [models.Index(fields=["email", "joined_at"])] +import re +from urllib.parse import urlparse + +from django.conf import settings +from django.contrib.auth.models import AbstractUser +from django.core.mail import send_mail +from django.db import models +from django.db.models import CharField +from django.template.loader import render_to_string +from django.urls import reverse +from django.utils.translation import gettext_lazy as _ +from django.contrib.sites.models import Site +from sorl.thumbnail import get_thumbnail +from taggit.managers import TaggableManager +from taggit.models import TagBase, TaggedItemBase +from django.contrib.sites.models import Site +from shortuuid.django_fields import ShortUUIDField +import logging + + +logger = logging.getLogger(__name__) + +# this is the string we use to identify an admin group +ADMIN_NAME = "admin" + + +class User(AbstractUser): + # First Name and Last Name do not cover name patterns + # around the globe. + name = CharField(_("Name of User"), blank=True, max_length=255) + + def get_absolute_url(self): + return reverse("users:detail", kwargs={"username": self.username}) + + def has_profile(self) -> bool: + """ + A convenience function to safely check if + a user has a matching profile. + """ + matching_profiles = Profile.objects.filter(user_id=self.id) + if matching_profiles: + return True + + def is_in_group(self, group_name: str) -> bool: + """ """ + if self.groups.filter(name=group_name).exists(): + return True + + return False + + def is_admin(self) -> bool: + """ """ + return self.is_in_group("admin") + + +class Cluster(TagBase): + class Meta: + verbose_name = _("Cluster") + verbose_name_plural = _("Clusters") + + +class TaggedCluster(TaggedItemBase): + content_object = models.ForeignKey("Profile", on_delete=models.CASCADE) + + tag = models.ForeignKey( + Cluster, + on_delete=models.CASCADE, + related_name="%(app_label)s_%(class)s_items", + ) + + +def flat_tag_list(tag_queryset) -> list[dict]: + """ + Return a list of tags, with the name split on a colon to allow for grouping + by the kind of tag listed. + This is called multiple times, so we split on the name in python rather than + going back to the database using further filters / exclude classes, which + can cause N+1 queries + """ + tag_list = [] + + for tag in tag_queryset.all(): + split_name = tag.name.split(":") + if len(split_name) == 1: + # there was no colon to split on use the full tag name + tag_list.append({"name": split_name[0], "tag": tag}) + if len(split_name) > 1: + # we DO have a colon to split on - use the full tag name + # and add the group, so we can use it for show the kinds + # of tags as well + tag_list.append({"group": split_name[0], "name": split_name[1], "tag": tag}) + + return tag_list + + +class Profile(models.Model): + user = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) + phone = models.CharField(_("phone"), max_length=254, blank=True, null=True) + website = models.URLField(_("website"), max_length=200, blank=True, null=True) + organisation = models.CharField( + _("organisation"), max_length=254, blank=True, null=True + ) + social_1 = models.CharField(_("social_1"), max_length=254, blank=True, null=True) + social_2 = models.CharField(_("social_2"), max_length=254, blank=True, null=True) + social_3 = models.CharField(_("social_3"), max_length=254, blank=True, null=True) + bio = models.TextField(_("bio"), blank=True, null=True) + visible = models.BooleanField(_("visible"), default=False) + location = models.CharField(_("location"), max_length=254, blank=True, null=True) + photo = models.ImageField( + _("photo"), blank=True, null=True, max_length=200, upload_to="photos" + ) + + tags = TaggableManager(blank=True) + clusters = TaggableManager("Clusters", blank=True, through=TaggedCluster) + + # short_id is a unique identifier for a profile, used in the URL + short_id = ShortUUIDField(length=8, unique=True, blank=True, null=True) + + # for tracking where this profile was imported from + import_id = models.CharField( + _("import_code"), max_length=254, blank=True, null=True + ) + # we have these as cache tables because when we use object storage + # we end up making loads of requests to AWS just to return an url + _photo_url = models.URLField(blank=True, null=True) + _photo_thumbnail_url = models.URLField(blank=True, null=True) + _photo_detail_url = models.URLField(blank=True, null=True) + + class Meta: + verbose_name = _("Profile") + verbose_name_plural = _("Profiles") + ordering = ["user__name"] + permissions = [ + ("set_visibility", "Can set the visibility of a profile"), + ("send_invite_email", "Can send profile invite emails"), + ( + "import_profiles", + "Can import profiles from a CSV file, or from a Firebase export JSON file", + ), + ] + + @property + def name(self): + return self.user.name + + @name.setter + def name(self, value): + self.user.name = value + + @property + def email(self): + return self.user.email + + @property + def admin(self): + return self.user.is_staff + + @property + def thumbnail_photo(self): + if not self.photo: + return None + + if self._photo_thumbnail_url: + return self._photo_thumbnail_url + + return get_thumbnail(self.photo, "100x100", crop="center", quality=99).url + + @property + def detail_photo(self): + """ + A photo, designed for showing on a page, when viewing a profile, + with a user's details + + """ + if not self.photo: + return None + + if self._photo_detail_url: + return self._photo_detail_url + + return get_thumbnail(self.photo, "250x250", crop="center", quality=99).url + + @property + def social_1_name(self): + """ + Extracts and returns the human-readable name of the website from the URL. + For example, 'https://www.example.com/path?query=1' will return 'example'. + """ + # Parse the URL to get the netloc (network location part) + parsed_url = urlparse(self.social_1) + domain = parsed_url.netloc + + # Remove the 'www.' prefix if present + domain = domain.replace('www.', '') + + # Extract the base domain without subdomains and TLD + match = re.match(r'^([^\.]+)\.', domain) + name = match.group(1) if match else domain + return name.capitalize() + + @property + def social_2_name(self): + """ + Extracts and returns the human-readable name of the website from the URL. + For example, 'https://www.example.com/path?query=1' will return 'example'. + """ + # Parse the URL to get the netloc (network location part) + parsed_url = urlparse(self.social_2) + domain = parsed_url.netloc + + # Remove the 'www.' prefix if present + domain = domain.replace('www.', '') + + # Extract the base domain without subdomains and TLD + match = re.match(r'^([^\.]+)\.', domain) + name = match.group(1) if match else domain + return name.capitalize() + + @property + def social_3_name(self): + """ + Extracts and returns the human-readable name of the website from the URL. + For example, 'https://www.example.com/path?query=1' will return 'example'. + """ + # Parse the URL to get the netloc (network location part) + parsed_url = urlparse(self.social_3) + domain = parsed_url.netloc + + # Remove the 'www.' prefix if present + domain = domain.replace('www.', '') + + # Extract the base domain without subdomains and TLD + match = re.match(r'^([^\.]+)\.', domain) + name = match.group(1) if match else domain + return name.capitalize() + + def tags_by_grouping(self): + """ + Return a list of tags, grouped by their type + """ + grouped_tags = {} + ungrouped_tags = [] + # group tags in a dict based on the name of the tag, once it is split at the ":" in the name + for tag in self.tags.filter(name__icontains=":").order_by("name"): + try: + tag_group, tag_name = tag.name.split(":") + tag_name = tag.name.split(":")[1] + if tag_group not in grouped_tags: + grouped_tags[tag_group] = [] + grouped_tags[tag_group].append({"name": tag_name, "tag": tag}) + except ValueError: + logger.warning(f"Unable to split tag name: {tag.name}. Not showing.") + + for ungrouped_tag in self.tags.exclude(name__icontains=":").order_by("name"): + ungrouped_tags.append({"name": ungrouped_tag.name, "tag": ungrouped_tag}) + + return [grouped_tags, ungrouped_tags] + + def tags_with_no_grouping(self): + """ + Return a list of tags, flattened into a single list + """ + return flat_tag_list(self.tags) + # tag_list = [] + # # group tags in a dict based on the name of the tag, once it is split at the ":" in the name + # for tag in self.tags.filter(name__icontains=":"): + # tag_group, tag_name = tag.name.split(":") + # tag_list.append({"name": tag_name, "tag": tag}) + + # for ungrouped_tag in self.tags.exclude(name__icontains=":"): + # tag_list.append({"name": ungrouped_tag.name, "tag": ungrouped_tag}) + + # return tag_list + + def __str__(self): + if self.user.name: + return f"{self.user.name} - {self.import_id}" + else: + return f"{self.user.first_name} - {self.import_id}" + + def get_absolute_url(self): + return reverse("profile-detail", args=[self.short_id]) + + def update_thumbnail_urls(self): + """Generate the thumbnails for a profile""" + if self.photo: + self._photo_url = self.photo.url + self._photo_thumbnail_url = get_thumbnail( + self.photo, "100x100", crop="center", quality=99 + ).url + self._photo_detail_url = get_thumbnail( + self.photo, "250x250", crop="center", quality=99 + ).url + + self.save( + update_fields=[ + "_photo_thumbnail_url", + "_photo_detail_url", + "_photo_url", + ] + ) + + def get_context_for_emails(self) -> dict: + current_site = Site.objects.get_current() + constellation = Constellation.objects.get(site=current_site) + email_confirmation = SendInviteEmailContent.objects.filter(site=current_site).first() + support_email_address = settings.SUPPORT_EMAIL + + return { + "profile": self, + "support_email_address": support_email_address, + "constellation": constellation, + "site": current_site, + "email_confirmation":email_confirmation, + } + + def send_invite_mail(self): + context = self.get_context_for_emails() + rendered_templates = self.generate_invite_mail() + print(":::::::::::::::::::::::::::::::::::::::::::") + send_mail( + f"Welcome to { context['site'].name }", + rendered_templates["text"], + context.get("support_email_address"), + [self.user.email], + html_message=rendered_templates["html"], + ) + + + def generate_invite_mail(self): + from django.template import Template, Context + + context = self.get_context_for_emails() + + # Fetch the email confirmation template content (assuming you have it stored in the context) + email_confirmation = context.get("email_confirmation") + + if email_confirmation: + # Create a Django Template object from the email content + email_content_template = Template(email_confirmation.email_content) + + # Render the template with the current context + rendered_email_content = email_content_template.render(Context(context)) + + # Update the context with the rendered email content + context["email_confirmation_content"] = rendered_email_content + else: + # If no custom email template is found, use a default message with templating + default_message_template = Template( + "

Dear {{ profile.name }}

" + "

Welcome to {{ constellation }}.

" + "

(Copy goes here to mention terms of service and community guidelines)

" + "

If you have any problems logging in, or you have not attempted to log in, please contact support.

" + ) + + context["email_confirmation_content"] = default_message_template.render(Context(context)) + + + # Now render the invite email as plain text and MJML + rendered_invite_txt = render_to_string( + "invite_new_profile.txt", + context, + ) + rendered_invite_html = render_to_string( + "invite_new_profile.mjml.html", + context, + ) + + # Optionally, view the rendered HTML in the browser for testing/debugging + from cl8.utils.templating import view_rendered_html_in_browser + view_rendered_html_in_browser(rendered_invite_html) + + return {"text": rendered_invite_txt, "html": rendered_invite_html} + +class Constellation(models.Model): + """ + A Constellation is a name we give to a specific 'container' for the members + using constellate. Constellations might have a description, some specific welcome text, and logo to members recognise it. + """ + + site = models.OneToOneField( + Site, + on_delete=models.CASCADE, + primary_key=True, + related_name="profiles", + verbose_name="site", + ) + + logo = models.ImageField( + _("logo"), + blank=True, + null=True, + max_length=200, + upload_to="logos", + ) + background_color = models.CharField( + blank=True, + max_length=256, + help_text="A hex code colour to use for the header background colour", + ) + text_color = models.CharField( + blank=True, + max_length=256, + help_text="A hex code colour to use for the header text colour", + ) + favicon = models.ImageField( + _("favicon"), + blank=True, + null=True, + max_length=200, + upload_to="favicons", + ) + welcome_message = models.TextField(null=True, blank=True) + welcome_heading = models.TextField(null=True, blank=True) + button = models.TextField(null=True, blank=True) + + + signin_via_slack = models.BooleanField(default=False) + signin_via_email = models.BooleanField(default=True) + + def __str__(self): + return f"{self.site.name}" + + +class CATJoinRequest(models.Model): + joined_at = models.DateTimeField() + email = models.EmailField(unique=False) + city_country = models.CharField(blank=True, max_length=256) + # Why are you interested in joining? + why_join = models.TextField(blank=True) + # What's the main thing you'd like to offer as a part of the community? + main_offer = models.TextField(blank=True) + + def __str__(self): + return f"{self.joined_at.strftime('%Y-%m-%d')} - {self.email}" + + def bio_text_from_join_request(self): + """ + Return a markdown version of the responses given when joining. Used to fill + out a member's profile. + """ + return f""" +### Why are you interested in joining? + +{self.why_join} + + +### What's the main thing you'd like to offer as a part of the community? +{self.main_offer} +""" + + class Meta: + indexes = [models.Index(fields=["email", "joined_at"])] + + +class SendInviteEmailContent(models.Model): + site = models.OneToOneField( + Site, + on_delete=models.CASCADE, + primary_key=True, + related_name="email_confirm_template", + ) + + email_title = models.TextField( + blank=True, + null=True, + help_text="Enter the subject of the email. For the site name, use `{{ constellation.site.name }}`.", + default="Welcome to {{ constellation.site.name }}" + ) + email_content = models.TextField( + blank=True, + help_text=( + "Enter the body of the email. " + "Use `{{ profile.name }}` for the user's name , `{{ constellation }}` for the site name and {{ support_email_address }} for the support email adrdress." + ), + default=( + "

Dear {{ profile.name }}

" + "

Welcome to {{ constellation }}.

" + "

(Copy goes here to mention terms of service and community guidelines)

" + "

If you have any problems logging in, or you have not attempted to log in, please contact support.

" + ) + ) + + def __str__(self): + return f"{self.site.name}" + +class SendInviteEmailContent(models.Model): + site = models.OneToOneField( + Site, + on_delete=models.CASCADE, + primary_key=True, + related_name="email_confirm_template", + ) + + email_title = models.TextField( + blank=True, + null=True, + help_text="Enter the subject of the email. For the site name, use `{{ constellation }}`.", + default="Welcome to {{ constellation }}" + ) + email_content = models.TextField( + blank=True, + help_text=( + "Enter the body of the email. " + "Use `{{ profile.name }}` for the user's name , `{{ constellation }}` for the site name and {{ support_email_address }} for the support email adrdress." + ), + default=( + "

Dear {{ profile.name }}

" + "

Welcome to {{ constellation }}.

" + "

(Copy goes here to mention terms of service and community guidelines)

" + "

If you have any problems logging in, or you have not attempted to log in, please contact support.

" + ) + ) + + def __str__(self): + return f"{self.site.name}" + + +class PasswordResetEmailContent(models.Model): + site = models.OneToOneField( + Site, + on_delete=models.CASCADE, + primary_key=True, + related_name="password_reset_template", + ) + + email_title = models.TextField( + blank=True, + null=True, + help_text="Enter the subject of the email. For the site name, use `{{ constellation }}`.", + default="Password Reset Request for {{ constellation }}" + ) + email_content = models.TextField( + blank=True, + help_text=( + "Enter the body of the email. " + "Use `{{ profile.name }}` for the user's username, `{{ reset_link }}` for the password reset link, and `{{ constellation }}` for the site name." + ), + default=( + "

Hello {{ profile.name }},

" + "

You requested a password reset for your account on {{ constellation }}.

" + "

Click the link below to reset your password:

" + "

Reset Password

" + "

If you did not request this email, please ignore it.

" + "

Thank you!

" + ) + ) + + def __str__(self): + return f"Password Reset Template for {self.site.name}" \ No newline at end of file diff --git a/cl8/users/templatetags/custom_tags.py b/cl8/users/templatetags/custom_tags.py new file mode 100644 index 0000000..149fdb2 --- /dev/null +++ b/cl8/users/templatetags/custom_tags.py @@ -0,0 +1,16 @@ +from django import template +from django.contrib.auth import get_user_model +from ..models import Profile + +register = template.Library() +User = get_user_model() + +@register.filter +def custom_user_display(user): + if not isinstance(user, User): + return '' + + profile = Profile.objects.filter(user=user).first() + if profile: + return profile.name + return f"{user.first_name} {user.last_name}" diff --git a/config/settings/base.py b/config/settings/base.py index e290ef1..b6e8ce1 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -1,380 +1,390 @@ -""" -Base settings to build other settings files upon. -""" -from pathlib import Path - -import environ - -ROOT_DIR = Path(__file__).resolve(strict=True).parent.parent.parent -PROJECT_DIR = ROOT_DIR -APPS_DIR = ROOT_DIR / "cl8" -env = environ.Env() - -READ_DOT_ENV_FILE = env.bool("DJANGO_READ_DOT_ENV_FILE", default=False) -if READ_DOT_ENV_FILE: - # OS environment variables take precedence over variables from .env - env.read_env(str(ROOT_DIR / ".env")) - -# GENERAL -# ------------------------------------------------------------------------------ -# https://docs.djangoproject.com/en/dev/ref/settings/#debug -DEBUG = env.bool("DJANGO_DEBUG", False) -# Local time zone. Choices are -# http://en.wikipedia.org/wiki/List_of_tz_zones_by_name -# though not all of them may be available with every OS. -# In Windows, this must be set to your system time zone. -TIME_ZONE = "UTC" -# https://docs.djangoproject.com/en/dev/ref/settings/#language-code -LANGUAGE_CODE = "en-us" -# https://docs.djangoproject.com/en/dev/ref/settings/#site-id -SITE_ID = 1 -# https://docs.djangoproject.com/en/dev/ref/settings/#use-i18n -USE_I18N = True -# https://docs.djangoproject.com/en/dev/ref/settings/#use-l10n -USE_L10N = True -# https://docs.djangoproject.com/en/dev/ref/settings/#use-tz -USE_TZ = True -# https://docs.djangoproject.com/en/dev/ref/settings/#locale-paths -LOCALE_PATHS = [str(ROOT_DIR / "locale")] - -# DATABASES -# ------------------------------------------------------------------------------ -# https://docs.djangoproject.com/en/dev/ref/settings/#databases - -DATABASES = { - # you almost definitely should be using postgres for development too - # to avoid surprises for development, but you CAN use sqlite - # replace the "postgres:///backend" with "sqlite:///backend_db", - # or pass it in as an environment variable - "default": env.db("DATABASE_URL", default="postgres:///cl8") -} -DATABASES["default"]["ATOMIC_REQUESTS"] = True - -# URLS -# ------------------------------------------------------------------------------ -# https://docs.djangoproject.com/en/dev/ref/settings/#root-urlconf -ROOT_URLCONF = "config.urls" -# https://docs.djangoproject.com/en/dev/ref/settings/#wsgi-application -WSGI_APPLICATION = "config.wsgi.application" - -# APPS -# ------------------------------------------------------------------------------ -DJANGO_APPS = [ - "django.contrib.auth", - "django.contrib.contenttypes", - "django.contrib.sessions", - "django.contrib.sites", - "django.contrib.messages", - "django.contrib.flatpages", - "django.contrib.staticfiles", - # "django.contrib.humanize", # Handy template tags - # "django.contrib.admin", - "django.forms", -] -THIRD_PARTY_APPS = [ - "allauth", - "allauth.account", - "allauth.socialaccount", - "rest_framework", - "rest_framework.authtoken", - "drfpasswordless", - "django_gravatar", - "taggit", - "taggit_labels", - "dal", - "dal_select2", - "corsheaders", - "mjml", - "sorl.thumbnail", - "tailwind", - "theme", - "django_extensions", - "widget_tweaks", -] - -LOCAL_APPS = [ - "cl8.users.apps.UsersConfig", - "cl8.apps.AdminConfig", - # slack auth scheme changed so we need our own version now - "cl8.users.slack_openid_connect", -] -# https://docs.djangoproject.com/en/dev/ref/settings/#installed-apps -INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS - -# MIGRATIONS -# ------------------------------------------------------------------------------ -# https://docs.djangoproject.com/en/dev/ref/settings/#migration-modules -MIGRATION_MODULES = {"sites": "cl8.contrib.sites.migrations"} - -# AUTHENTICATION -# ------------------------------------------------------------------------------ -# https://docs.djangoproject.com/en/dev/ref/settings/#authentication-backends -# https://django-allauth.readthedocs.io/en/latest/installation.html -AUTHENTICATION_BACKENDS = [ - "django.contrib.auth.backends.ModelBackend", - "allauth.account.auth_backends.AuthenticationBackend", -] -# https://docs.djangoproject.com/en/dev/ref/settings/#auth-user-model -AUTH_USER_MODEL = "users.User" -# https://docs.djangoproject.com/en/dev/ref/settings/#login-redirect-url -LOGIN_REDIRECT_URL = "home" -# https://docs.djangoproject.com/en/dev/ref/settings/#login-url -LOGIN_URL = "account_login" - -PASSWORDLESS_AUTH = { - "PASSWORDLESS_AUTH_TYPES": ["EMAIL"], - "PASSWORDLESS_EMAIL_NOREPLY_ADDRESS": "noreply@greening.digital", - "PASSWORDLESS_REGISTER_NEW_USERS": False, - "PASSWORDLESS_EMAIL_SUBJECT": "Your Constellation Login Code", - "PASSWORDLESS_MOBILE_MESSAGE": ( - "Use this code to log in: %s. This code is valid for the " - "next 30 minutes. You can request a new code at any time." - ), - "PASSWORDLESS_EMAIL_TOKEN_HTML_TEMPLATE_NAME": "passwordless_default_token_email.mjml.html", # noqa - "PASSWORDLESS_CONTEXT_PROCESSORS": [ - "cl8.utils.context_processors.support_email", - ], -} -SUPPORT_EMAIL = env("DJANGO_SUPPORT_EMAIL", default="info@greening.digital") - - -# PASSWORDS -# ------------------------------------------------------------------------------ -# https://docs.djangoproject.com/en/dev/ref/settings/#password-hashers -PASSWORD_HASHERS = [ - # https://docs.djangoproject.com/en/dev/topics/auth/passwords/#using-argon2-with-django - "django.contrib.auth.hashers.Argon2PasswordHasher", - "django.contrib.auth.hashers.PBKDF2PasswordHasher", - "django.contrib.auth.hashers.PBKDF2SHA1PasswordHasher", - "django.contrib.auth.hashers.BCryptSHA256PasswordHasher", -] -# https://docs.djangoproject.com/en/dev/ref/settings/#auth-password-validators -AUTH_PASSWORD_VALIDATORS = [ - { - "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator" # noqa - }, - {"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"}, - {"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"}, - {"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"}, -] - -# MIDDLEWARE -# ------------------------------------------------------------------------------ -# https://docs.djangoproject.com/en/dev/ref/settings/#middleware -MIDDLEWARE = [ - "corsheaders.middleware.CorsMiddleware", - "django.middleware.common.CommonMiddleware", - "django.middleware.security.SecurityMiddleware", - "whitenoise.middleware.WhiteNoiseMiddleware", - "django.contrib.sessions.middleware.SessionMiddleware", - "django.middleware.locale.LocaleMiddleware", - "django.middleware.common.CommonMiddleware", - "django.middleware.csrf.CsrfViewMiddleware", - "django.contrib.auth.middleware.AuthenticationMiddleware", - "django.contrib.messages.middleware.MessageMiddleware", - "django.middleware.common.BrokenLinkEmailsMiddleware", - "django.middleware.clickjacking.XFrameOptionsMiddleware", - "django.contrib.sites.middleware.CurrentSiteMiddleware", - "cl8.users.middleware.ConstellationMiddleware", - "allauth.account.middleware.AccountMiddleware", - "django_htmx.middleware.HtmxMiddleware", -] - -# STATIC -# ------------------------------------------------------------------------------ -# https://docs.djangoproject.com/en/dev/ref/settings/#static-root -STATIC_ROOT = str(ROOT_DIR / "staticfiles") -# https://docs.djangoproject.com/en/dev/ref/settings/#static-url -STATIC_URL = "/static/" -# https://docs.djangoproject.com/en/dev/ref/contrib/staticfiles/#std:setting-STATICFILES_DIRS -STATICFILES_DIRS = [str(APPS_DIR / "static")] -# https://docs.djangoproject.com/en/dev/ref/contrib/staticfiles/#staticfiles-finders -STATICFILES_FINDERS = [ - "django.contrib.staticfiles.finders.FileSystemFinder", - "django.contrib.staticfiles.finders.AppDirectoriesFinder", -] - -# MEDIA -# ------------------------------------------------------------------------------ -# https://docs.djangoproject.com/en/dev/ref/settings/#media-root -MEDIA_ROOT = str(APPS_DIR / "media") -# https://docs.djangoproject.com/en/dev/ref/settings/#media-url -MEDIA_URL = "/media/" - -# TEMPLATES -# ------------------------------------------------------------------------------ -# https://docs.djangoproject.com/en/dev/ref/settings/#templates -TEMPLATES = [ - { - # https://docs.djangoproject.com/en/dev/ref/settings/#std:setting-TEMPLATES-BACKEND - "BACKEND": "django.template.backends.django.DjangoTemplates", - # https://docs.djangoproject.com/en/dev/ref/settings/#template-dirs - "DIRS": [ - # use the styles in themes - str(ROOT_DIR / "theme" / "templates"), - # then fall back to cl8 defaults - str(APPS_DIR / "templates"), - ], - "OPTIONS": { - # https://docs.djangoproject.com/en/dev/ref/settings/#template-loaders - # https://docs.djangoproject.com/en/dev/ref/templates/api/#loader-types - "loaders": [ - "django.template.loaders.filesystem.Loader", - "django.template.loaders.app_directories.Loader", - ], - # https://docs.djangoproject.com/en/dev/ref/settings/#template-context-processors - "context_processors": [ - "django.template.context_processors.debug", - "django.template.context_processors.request", - "django.contrib.auth.context_processors.auth", - "django.template.context_processors.i18n", - "django.template.context_processors.media", - "django.template.context_processors.static", - "django.template.context_processors.tz", - "django.contrib.messages.context_processors.messages", - "cl8.utils.context_processors.settings_context", - ], - }, - } -] - -# https://docs.djangoproject.com/en/dev/ref/settings/#form-renderer -FORM_RENDERER = "django.forms.renderers.TemplatesSetting" - - -# FIXTURES -# ------------------------------------------------------------------------------ -# https://docs.djangoproject.com/en/dev/ref/settings/#fixture-dirs -FIXTURE_DIRS = (str(APPS_DIR / "fixtures"),) - -# SECURITY -# ------------------------------------------------------------------------------ -# https://docs.djangoproject.com/en/dev/ref/settings/#session-cookie-httponly -SESSION_COOKIE_HTTPONLY = True -# https://docs.djangoproject.com/en/dev/ref/settings/#csrf-cookie-httponly -CSRF_COOKIE_HTTPONLY = False -CSRF_USE_SESSIONS = False -# https://docs.djangoproject.com/en/dev/ref/settings/#secure-browser-xss-filter -SECURE_BROWSER_XSS_FILTER = True -# https://docs.djangoproject.com/en/dev/ref/settings/#x-frame-options -X_FRAME_OPTIONS = "DENY" - -# EMAIL -# ------------------------------------------------------------------------------ -# https://docs.djangoproject.com/en/dev/ref/settings/#email-backend -EMAIL_BACKEND = env( - "DJANGO_EMAIL_BACKEND", default="django.core.mail.backends.smtp.EmailBackend" -) -# https://docs.djangoproject.com/en/dev/ref/settings/#email-timeout -EMAIL_TIMEOUT = 5 - -# ADMIN -# ------------------------------------------------------------------------------ -# Django Admin URL. -ADMIN_URL = "admin/" -# https://docs.djangoproject.com/en/dev/ref/settings/#admins -ADMINS = [("""Chris Adams""", "chris@productscience.net")] -# https://docs.djangoproject.com/en/dev/ref/settings/#managers -MANAGERS = ADMINS - -# LOGGING -# ------------------------------------------------------------------------------ -# https://docs.djangoproject.com/en/dev/ref/settings/#logging -# See https://docs.djangoproject.com/en/dev/topics/logging for -# more details on how to customize your logging configuration. -LOGGING = { - "version": 1, - "disable_existing_loggers": False, - "formatters": { - "verbose": { - "format": "%(levelname)s %(asctime)s %(module)s " - "%(process)d %(thread)d %(message)s" - } - }, - "handlers": { - "console": { - "level": "DEBUG", - "class": "rich.logging.RichHandler", - "formatter": "verbose", - } - }, - "root": {"level": "INFO", "handlers": ["console"]}, -} - - -# django-allauth -# ------------------------------------------------------------------------------ -ACCOUNT_ALLOW_REGISTRATION = env.bool("DJANGO_ACCOUNT_ALLOW_REGISTRATION", True) -# https://django-allauth.readthedocs.io/en/latest/configuration.html -ACCOUNT_AUTHENTICATION_METHOD = "email" -# https://django-allauth.readthedocs.io/en/latest/configuration.html -ACCOUNT_EMAIL_REQUIRED = True -# https://django-allauth.readthedocs.io/en/latest/configuration.html -ACCOUNT_EMAIL_VERIFICATION = "none" -# https://django-allauth.readthedocs.io/en/latest/configuration.html -ACCOUNT_ADAPTER = "cl8.users.adapters.AccountAdapter" -# https://django-allauth.readthedocs.io/en/latest/configuration.html - -ACCOUNT_DEFAULT_HTTP_PROTOCOL = "https" -ACCOUNT_UNIQUE_EMAIL = True -SOCIALACCOUNT_ADAPTER = "cl8.users.adapters.Cl8SocialAccountAdapter" -SOCIALACCOUNT_EMAIL_REQUIRED = False -SOCIALACCOUNT_PROVIDERS = { - "slack_openid_connect": { - "APP": { - "client_id": env.str("DJANGO_SLACK_CLIENT_ID", default=None), - "secret": env.str("DJANGO_SLACK_SECRET", default=None), - "token": env.str("DJANGO_SLACK_USER_TOKEN", default=None), - }, - "SCOPE": ["openid", "email", "profile"], - }, -} - - -# django-rest-framework -# ------------------------------------------------------------------------------- -# django-rest-framework - https://www.django-rest-framework.org/api-guide/settings/ -REST_FRAMEWORK = { - "DEFAULT_AUTHENTICATION_CLASSES": ( - "rest_framework.authentication.SessionAuthentication", - "rest_framework.authentication.TokenAuthentication", - ), - "DEFAULT_PERMISSION_CLASSES": ("rest_framework.permissions.IsAuthenticated",), -} -# Your stuff... -# ------------------------------------------------------------------------------ - -MJML_CHECK_CMD_ON_STARTUP = True -MJML_PATH = str(PROJECT_DIR / "theme" / "static_src" / "node_modules/.bin/mjml") -MJML_EXEC_CMD = [MJML_PATH, "--config.validationLevel", "skip"] - -MODERATOR_GROUP_NAME = "Constellation Moderators" - -# Photos - -THUMBNAIL_DEBUG = False - -# slack connection for the server, not the user -SLACK_TOKEN = env.str("DJANGO_SLACK_TOKEN", default=None) -SLACK_CHANNEL_NAME = env.str("DJANGO_SLACK_CHANNEL_NAME", default=None) -SLACK_SIGNIN_AUTHORIZE_URL = env.str( - "DJANGO_SLACK_SIGNIN_AUTHORIZE_URL", - default="https://slack.com/openid/connect/authorize", -) - - -TAILWIND_APP_NAME = "theme" - - -# the identufying key for the google spreadsheet we pull data from -GSPREAD_KEY = env.str("DJANGO_GSPREAD_SPREADSHEET_KEY", default=None) -GSPREAD_SERVICE_ACCOUNT = env.str( - "DJANGO_GSPREAD_SERVICE_ACCOUNT_FILE_PATH", default=None -) - - -AIRTABLE_BEARER_TOKEN = env.str("DJANGO_AIRTABLE_BEARER_TOKEN", default=None) -AIRTABLE_BASE = env.str("DJANGO_AIRTABLE_BASE", default=None) -AIRTABLE_TABLE = env.str("DJANGO_AIRTABLE_TABLE", default=None) - -DEFAULT_AUTO_FIELD = "django.db.models.AutoField" +""" +Base settings to build other settings files upon. +""" +from pathlib import Path + +import environ + +ROOT_DIR = Path(__file__).resolve(strict=True).parent.parent.parent +PROJECT_DIR = ROOT_DIR +APPS_DIR = ROOT_DIR / "cl8" +env = environ.Env() + +READ_DOT_ENV_FILE = env.bool("DJANGO_READ_DOT_ENV_FILE", default=False) +if READ_DOT_ENV_FILE: + # OS environment variables take precedence over variables from .env + env.read_env(str(ROOT_DIR / ".env")) + +# ------------------------------------------------------------------------------ +# https://docs.djangoproject.com/en/dev/ref/settings/#debug +DEBUG = env.bool("DJANGO_DEBUG", False) +# Local time zone. Choices are +# http://en.wikipedia.org/wiki/List_of_tz_zones_by_name +# though not all of them may be available with every OS. +# In Windows, this must be set to your system time zone. +TIME_ZONE = "UTC" +# https://docs.djangoproject.com/en/dev/ref/settings/#language-code +LANGUAGE_CODE = "en-us" +# https://docs.djangoproject.com/en/dev/ref/settings/#site-id +SITE_ID = 1 +# https://docs.djangoproject.com/en/dev/ref/settings/#use-i18n +USE_I18N = True +# https://docs.djangoproject.com/en/dev/ref/settings/#use-l10n +USE_L10N = True +# https://docs.djangoproject.com/en/dev/ref/settings/#use-tz +USE_TZ = True +# https://docs.djangoproject.com/en/dev/ref/settings/#locale-paths +LOCALE_PATHS = [str(ROOT_DIR / "locale")] + +# DATABASES +# ------------------------------------------------------------------------------ +# https://docs.djangoproject.com/en/dev/ref/settings/#databases + +DATABASES = { + # you almost definitely should be using postgres for development too + # to avoid surprises for development, but you CAN use sqlite + # replace the "postgres:///backend" with "sqlite:///backend_db", + # or pass it in as an environment variable + "default": env.db("DATABASE_URL", default="postgres:///cl8") +} +DATABASES["default"]["ATOMIC_REQUESTS"] = True + + +# URLS +# ------------------------------------------------------------------------------ +# https://docs.djangoproject.com/en/dev/ref/settings/#root-urlconf +ROOT_URLCONF = "config.urls" +# https://docs.djangoproject.com/en/dev/ref/settings/#wsgi-application +WSGI_APPLICATION = "config.wsgi.application" + +# APPS +# ------------------------------------------------------------------------------ +DJANGO_APPS = [ + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.sites", + "django.contrib.messages", + "django.contrib.flatpages", + "django.contrib.staticfiles", + # "django.contrib.humanize", # Handy template tags + # "django.contrib.admin", + "django.forms", +] +THIRD_PARTY_APPS = [ + "cl8.users.apps.UsersConfig", + "allauth", + "allauth.account", + "allauth.socialaccount", + "rest_framework", + "rest_framework.authtoken", + "drfpasswordless", + "django_gravatar", + "taggit", + "taggit_labels", + "dal", + "dal_select2", + "corsheaders", + "mjml", + "sorl.thumbnail", + "tailwind", + "theme", + "django_extensions", + "widget_tweaks", +] + +LOCAL_APPS = [ + "cl8.apps.AdminConfig", + # slack auth scheme changed so we need our own version now + "cl8.users.slack_openid_connect", +] +# https://docs.djangoproject.com/en/dev/ref/settings/#installed-apps +INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS + +# MIGRATIONS +# ------------------------------------------------------------------------------ +# https://docs.djangoproject.com/en/dev/ref/settings/#migration-modules +MIGRATION_MODULES = {"sites": "cl8.contrib.sites.migrations"} + +# AUTHENTICATION +# ------------------------------------------------------------------------------ +# https://docs.djangoproject.com/en/dev/ref/settings/#authentication-backends +# https://django-allauth.readthedocs.io/en/latest/installation.html +AUTHENTICATION_BACKENDS = [ + "django.contrib.auth.backends.ModelBackend", + "allauth.account.auth_backends.AuthenticationBackend", +] +# https://docs.djangoproject.com/en/dev/ref/settings/#auth-user-model +AUTH_USER_MODEL = "users.User" +# https://docs.djangoproject.com/en/dev/ref/settings/#login-redirect-url +LOGIN_REDIRECT_URL = "home" +# https://docs.djangoproject.com/en/dev/ref/settings/#login-url +LOGIN_URL = "account_login" + +PASSWORDLESS_AUTH = { + "PASSWORDLESS_AUTH_TYPES": ["EMAIL"], + "PASSWORDLESS_EMAIL_NOREPLY_ADDRESS": "noreply@greening.digital", + "PASSWORDLESS_REGISTER_NEW_USERS": False, + "PASSWORDLESS_EMAIL_SUBJECT": "Your Constellation Login Code", + "PASSWORDLESS_MOBILE_MESSAGE": ( + "Use this code to log in: %s. This code is valid for the " + "next 30 minutes. You can request a new code at any time." + ), + "PASSWORDLESS_EMAIL_TOKEN_HTML_TEMPLATE_NAME": "passwordless_default_token_email.mjml.html", # noqa + "PASSWORDLESS_CONTEXT_PROCESSORS": [ + "cl8.utils.context_processors.support_email", + ], +} +SUPPORT_EMAIL = env("DJANGO_SUPPORT_EMAIL", default="info@greening.digital") + + +# PASSWORDS +# ------------------------------------------------------------------------------ +# https://docs.djangoproject.com/en/dev/ref/settings/#password-hashers +PASSWORD_HASHERS = [ + # https://docs.djangoproject.com/en/dev/topics/auth/passwords/#using-argon2-with-django + "django.contrib.auth.hashers.Argon2PasswordHasher", + "django.contrib.auth.hashers.PBKDF2PasswordHasher", + "django.contrib.auth.hashers.PBKDF2SHA1PasswordHasher", + "django.contrib.auth.hashers.BCryptSHA256PasswordHasher", +] +# https://docs.djangoproject.com/en/dev/ref/settings/#auth-password-validators +AUTH_PASSWORD_VALIDATORS = [ + { + "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator" # noqa + }, + {"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"}, + {"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"}, + {"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"}, +] + +# MIDDLEWARE +# ------------------------------------------------------------------------------ +# https://docs.djangoproject.com/en/dev/ref/settings/#middleware +MIDDLEWARE = [ + "corsheaders.middleware.CorsMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.security.SecurityMiddleware", + "whitenoise.middleware.WhiteNoiseMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.locale.LocaleMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.common.BrokenLinkEmailsMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", + "django.contrib.sites.middleware.CurrentSiteMiddleware", + # "cl8.users.middleware.ConstellationMiddleware", + "cl8.users.middleware.SiteConfigMiddleware", + "allauth.account.middleware.AccountMiddleware", + "django_htmx.middleware.HtmxMiddleware", +] + +# STATIC +# ------------------------------------------------------------------------------ +# https://docs.djangoproject.com/en/dev/ref/settings/#static-root +STATIC_ROOT = str(ROOT_DIR / "staticfiles") +# https://docs.djangoproject.com/en/dev/ref/settings/#static-url +STATIC_URL = "/static/" +# https://docs.djangoproject.com/en/dev/ref/contrib/staticfiles/#std:setting-STATICFILES_DIRS +STATICFILES_DIRS = [str(APPS_DIR / "static")] +# https://docs.djangoproject.com/en/dev/ref/contrib/staticfiles/#staticfiles-finders +STATICFILES_FINDERS = [ + "django.contrib.staticfiles.finders.FileSystemFinder", + "django.contrib.staticfiles.finders.AppDirectoriesFinder", +] + +# MEDIA +# ------------------------------------------------------------------------------ +# https://docs.djangoproject.com/en/dev/ref/settings/#media-root +MEDIA_ROOT = str(APPS_DIR / "media") +# https://docs.djangoproject.com/en/dev/ref/settings/#media-url +MEDIA_URL = "/media/" + +# TEMPLATES +# ------------------------------------------------------------------------------ +# https://docs.djangoproject.com/en/dev/ref/settings/#templates +TEMPLATES = [ + { + # https://docs.djangoproject.com/en/dev/ref/settings/#std:setting-TEMPLATES-BACKEND + "BACKEND": "django.template.backends.django.DjangoTemplates", + # https://docs.djangoproject.com/en/dev/ref/settings/#template-dirs + "DIRS": [ + # use the styles in themes + str(ROOT_DIR / "theme" / "templates"), + # then fall back to cl8 defaults + str(APPS_DIR / "templates"), + ], + "OPTIONS": { + # https://docs.djangoproject.com/en/dev/ref/settings/#template-loaders + # https://docs.djangoproject.com/en/dev/ref/templates/api/#loader-types + "loaders": [ + "django.template.loaders.filesystem.Loader", + "django.template.loaders.app_directories.Loader", + ], + # https://docs.djangoproject.com/en/dev/ref/settings/#template-context-processors + "context_processors": [ + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.template.context_processors.i18n", + "django.template.context_processors.media", + "django.template.context_processors.static", + "django.template.context_processors.tz", + "django.contrib.messages.context_processors.messages", + "cl8.utils.context_processors.settings_context", + ], + }, + } +] + +# https://docs.djangoproject.com/en/dev/ref/settings/#form-renderer +FORM_RENDERER = "django.forms.renderers.TemplatesSetting" + + +# FIXTURES +# ------------------------------------------------------------------------------ +# https://docs.djangoproject.com/en/dev/ref/settings/#fixture-dirs +FIXTURE_DIRS = (str(APPS_DIR / "fixtures"),) + +# SECURITY +# ------------------------------------------------------------------------------ +# https://docs.djangoproject.com/en/dev/ref/settings/#session-cookie-httponly +SESSION_COOKIE_HTTPONLY = True +# https://docs.djangoproject.com/en/dev/ref/settings/#csrf-cookie-httponly +CSRF_COOKIE_HTTPONLY = False +CSRF_USE_SESSIONS = False +# https://docs.djangoproject.com/en/dev/ref/settings/#secure-browser-xss-filter +SECURE_BROWSER_XSS_FILTER = True +# https://docs.djangoproject.com/en/dev/ref/settings/#x-frame-options +X_FRAME_OPTIONS = "DENY" + +# EMAIL +# ------------------------------------------------------------------------------ +# https://docs.djangoproject.com/en/dev/ref/settings/#email-backend +# EMAIL_BACKEND = env( +# "DJANGO_EMAIL_BACKEND", default="django.core.mail.backends.smtp.EmailBackend" +# ) +EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend' + +# https://docs.djangoproject.com/en/dev/ref/settings/#email-timeout +EMAIL_TIMEOUT = 5 + +# ADMIN +# ------------------------------------------------------------------------------ +# Django Admin URL. +ADMIN_URL = "admin/" +# https://docs.djangoproject.com/en/dev/ref/settings/#admins +ADMINS = [("""Chris Adams""", "chris@productscience.net")] +# https://docs.djangoproject.com/en/dev/ref/settings/#managers +MANAGERS = ADMINS + +# LOGGING +# ------------------------------------------------------------------------------ +# https://docs.djangoproject.com/en/dev/ref/settings/#logging +# See https://docs.djangoproject.com/en/dev/topics/logging for +# more details on how to customize your logging configuration. +LOGGING = { + "version": 1, + "disable_existing_loggers": False, + "formatters": { + "verbose": { + "format": "%(levelname)s %(asctime)s %(module)s " + "%(process)d %(thread)d %(message)s" + } + }, + "handlers": { + "console": { + "level": "DEBUG", + "class": "rich.logging.RichHandler", + "formatter": "verbose", + } + }, + "root": {"level": "INFO", "handlers": ["console"]}, +} + + +# django-allauth +# ------------------------------------------------------------------------------ +ACCOUNT_ALLOW_REGISTRATION = env.bool("DJANGO_ACCOUNT_ALLOW_REGISTRATION", True) +# https://django-allauth.readthedocs.io/en/latest/configuration.html +ACCOUNT_AUTHENTICATION_METHOD = "email" +# https://django-allauth.readthedocs.io/en/latest/configuration.html +ACCOUNT_EMAIL_REQUIRED = True +# https://django-allauth.readthedocs.io/en/latest/configuration.html +ACCOUNT_EMAIL_VERIFICATION = "none" +# https://django-allauth.readthedocs.io/en/latest/configuration.html +ACCOUNT_ADAPTER = "cl8.users.adapters.AccountAdapter" +# https://django-allauth.readthedocs.io/en/latest/configuration.html + +ACCOUNT_DEFAULT_HTTP_PROTOCOL = "https" +ACCOUNT_UNIQUE_EMAIL = True +SOCIALACCOUNT_ADAPTER = "cl8.users.adapters.Cl8SocialAccountAdapter" +SOCIALACCOUNT_EMAIL_REQUIRED = False +SOCIALACCOUNT_PROVIDERS = { + "slack_openid_connect": { + "APP": { + "client_id": env.str("DJANGO_SLACK_CLIENT_ID", default=None), + "secret": env.str("DJANGO_SLACK_SECRET", default=None), + "token": env.str("DJANGO_SLACK_USER_TOKEN", default=None), + }, + "SCOPE": ["openid", "email", "profile"], + }, +} + + +# django-rest-framework +# ------------------------------------------------------------------------------- +# django-rest-framework - https://www.django-rest-framework.org/api-guide/settings/ +REST_FRAMEWORK = { + "DEFAULT_AUTHENTICATION_CLASSES": ( + "rest_framework.authentication.SessionAuthentication", + "rest_framework.authentication.TokenAuthentication", + ), + "DEFAULT_PERMISSION_CLASSES": ("rest_framework.permissions.IsAuthenticated",), +} +# Your stuff... +# ------------------------------------------------------------------------------ +import os + +# MJML_CHECK_CMD_ON_STARTUP = True +# MJML_PATH = str(PROJECT_DIR / "theme" / "static_src" / "node_modules/.bin/mjml") +# MJML_EXEC_CMD = [MJML_PATH, "--config.validationLevel", "skip"] +MJML_CHECK_CMD_ON_STARTUP = True +MJML_PATH = str(PROJECT_DIR / "theme" / "static_src" / "node_modules/.bin/mjml.cmd" if os.name == 'nt' else PROJECT_DIR / "theme" / "static_src" / "node_modules/.bin/mjml") +MJML_EXEC_CMD = [MJML_PATH, "--config.validationLevel", "skip"] + +MODERATOR_GROUP_NAME = "Constellation Moderators" + +# Photos + +THUMBNAIL_DEBUG = False + +# slack connection for the server, not the user +SLACK_TOKEN = env.str("DJANGO_SLACK_TOKEN", default=None) +SLACK_CHANNEL_NAME = env.str("DJANGO_SLACK_CHANNEL_NAME", default=None) +SLACK_SIGNIN_AUTHORIZE_URL = env.str( + "DJANGO_SLACK_SIGNIN_AUTHORIZE_URL", + default="https://slack.com/openid/connect/authorize", +) + + +TAILWIND_APP_NAME = "theme" + + +# the identufying key for the google spreadsheet we pull data from +GSPREAD_KEY = env.str("DJANGO_GSPREAD_SPREADSHEET_KEY", default=None) +GSPREAD_SERVICE_ACCOUNT = env.str( + "DJANGO_GSPREAD_SERVICE_ACCOUNT_FILE_PATH", default=None +) + + +AIRTABLE_BEARER_TOKEN = env.str("DJANGO_AIRTABLE_BEARER_TOKEN", default=None) +AIRTABLE_BASE = env.str("DJANGO_AIRTABLE_BASE", default=None) +AIRTABLE_TABLE = env.str("DJANGO_AIRTABLE_TABLE", default=None) + +DEFAULT_AUTO_FIELD = "django.db.models.AutoField" + + +ACCOUNT_ADAPTER = 'cl8.users.api.views.CustomAccountAdapter' diff --git a/config/settings/local.py b/config/settings/local.py index 8dc6399..f98fcca 100644 --- a/config/settings/local.py +++ b/config/settings/local.py @@ -1,113 +1,135 @@ -from .base import * # noqa -from .base import env - -# GENERAL -# ------------------------------------------------------------------------------ -# https://docs.djangoproject.com/en/dev/ref/settings/#debug -DEBUG = True -# https://docs.djangoproject.com/en/dev/ref/settings/#secret-key -SECRET_KEY = env( - "DJANGO_SECRET_KEY", - default="RKQJk9Bq11Unmlwe9H5qRGek98kAYxX6GoARcG5j6Ho0wVKto8ox8lA0o0FeMvSc", -) -# https://docs.djangoproject.com/en/dev/ref/settings/#allowed-hosts -ALLOWED_HOSTS = [".localhost", "0.0.0.0", "127.0.0.1", "*"] - -CSRF_TRUSTED_ORIGINS = ["https://*.localhost"] - -# CACHES -# ------------------------------------------------------------------------------ -# https://docs.djangoproject.com/en/dev/ref/settings/#caches -CACHES = { - "default": { - "BACKEND": "django.core.cache.backends.locmem.LocMemCache", - "LOCATION": "", - } -} - -# EMAIL -# ------------------------------------------------------------------------------ -# https://docs.djangoproject.com/en/dev/ref/settings/#email-host -EMAIL_HOST = "localhost" -# https://docs.djangoproject.com/en/dev/ref/settings/#email-port -EMAIL_PORT = 1025 - -# WhiteNoise -# ------------------------------------------------------------------------------ -# http://whitenoise.evans.io/en/latest/django.html#using-whitenoise-in-development -INSTALLED_APPS = ["whitenoise.runserver_nostatic"] + INSTALLED_APPS # noqa F405 - - -# django-debug-toolbar -# ------------------------------------------------------------------------------ -# https://django-debug-toolbar.readthedocs.io/en/latest/installation.html#prerequisites -INSTALLED_APPS += [ - "debug_toolbar", -] # noqa F405 -# https://django-debug-toolbar.readthedocs.io/en/latest/installation.html#middleware -MIDDLEWARE += ["debug_toolbar.middleware.DebugToolbarMiddleware"] # noqa F405 -# https://django-debug-toolbar.readthedocs.io/en/latest/configuration. -# html#debug-toolbar-config - -DEBUG_TOOLBAR_PANELS = [ - # "debug_toolbar.panels.history.HistoryPanel", - "debug_toolbar.panels.versions.VersionsPanel", - "debug_toolbar.panels.timer.TimerPanel", - "debug_toolbar.panels.settings.SettingsPanel", - "debug_toolbar.panels.headers.HeadersPanel", - "debug_toolbar.panels.request.RequestPanel", - # "debug_toolbar.panels.sql.SQLPanel", - "debug_toolbar.panels.staticfiles.StaticFilesPanel", - "debug_toolbar.panels.templates.TemplatesPanel", - # "debug_toolbar.panels.cache.CachePanel", - # "debug_toolbar.panels.signals.SignalsPanel", - # "debug_toolbar.panels.logging.LoggingPanel", - # "debug_toolbar.panels.redirects.RedirectsPanel", - # "debug_toolbar.panels.profiling.ProfilingPanel", -] - -DEBUG_TOOLBAR_CONFIG = { - # "DISABLE_PANELS": ["debug_toolbar.panels.redirects.RedirectsPanel"], - "SHOW_TEMPLATE_CONTEXT": True, -} -# https://django-debug-toolbar.readthedocs.io/en/latest/installation.html#internal-ips -INTERNAL_IPS = ["127.0.0.1", "10.0.2.2"] - - -# browser reload -INSTALLED_APPS += [ - "django_browser_reload", -] - -MIDDLEWARE += ["django_browser_reload.middleware.BrowserReloadMiddleware"] - -# django-extensions -# ------------------------------------------------------------------------------ -# https://django-extensions.readthedocs.io/en/latest/installation_instructions.html#configuration -# INSTALLED_APPS += ["django_extensions"] # noqa F405 - -# Your stuff... -# ------------------------------------------------------------------------------ -CORS_ORIGIN_WHITELIST = [ - "http://localhost:8081", - "http://127.0.0.1:8081", - "http://localhost:8080", - "http://127.0.0.1:8080", - "https://cat.cl8.localhost", -] -CORS_ALLOW_CREDENTIALS = True - - -LOGGING = { - "version": 1, - "disable_existing_loggers": False, - "formatters": {"rich": {"datefmt": "[%X]"}}, - "handlers": { - "console": { - "class": "rich.logging.RichHandler", - "formatter": "rich", - "level": "DEBUG", - } - }, - "loggers": {"django": {"handlers": ["console"]}}, -} +from .base import * # noqa +from .base import env + +# GENERAL +# ------------------------------------------------------------------------------ +# https://docs.djangoproject.com/en/dev/ref/settings/#debug +DEBUG = True +# https://docs.djangoproject.com/en/dev/ref/settings/#secret-key +SECRET_KEY = env( + "DJANGO_SECRET_KEY", + default="RKQJk9Bq11Unmlwe9H5qRGek98kAYxX6GoARcG5j6Ho0wVKto8ox8lA0o0FeMvSc", +) +# https://docs.djangoproject.com/en/dev/ref/settings/#allowed-hosts +ALLOWED_HOSTS = [".localhost", "0.0.0.0", "127.0.0.1", "*"] + +CSRF_TRUSTED_ORIGINS = ["https://*.localhost"] + +# CACHES +# ------------------------------------------------------------------------------ +# https://docs.djangoproject.com/en/dev/ref/settings/#caches +CACHES = { + "default": { + "BACKEND": "django.core.cache.backends.locmem.LocMemCache", + "LOCATION": "", + } +} + +# EMAIL +# ------------------------------------------------------------------------------ +# https://docs.djangoproject.com/en/dev/ref/settings/#email-host +EMAIL_HOST = "localhost" +# https://docs.djangoproject.com/en/dev/ref/settings/#email-port +EMAIL_PORT = 1025 + + +# EMAIL_HOST='smtp.gmail.com' +# EMAIL_PORT=465 +# EMAIL_HOST_USER='' +# EMAIL_HOST_PASSWORD='' +# EMAIL_USE_SSL=True +# WhiteNoise +# ------------------------------------------------------------------------------ +# http://whitenoise.evans.io/en/latest/django.html#using-whitenoise-in-development +INSTALLED_APPS = ["whitenoise.runserver_nostatic"] + INSTALLED_APPS # noqa F405 + +# Make email confirmation mandatory for login +# ACCOUNT_EMAIL_VERIFICATION = "mandatory" + +# # Redirect URL after successful email confirmation +# ACCOUNT_EMAIL_CONFIRMATION_AUTHENTICATED_REDIRECT_URL = '/' + +# # Use email as the username +# ACCOUNT_USERNAME_REQUIRED = False +# ACCOUNT_EMAIL_REQUIRED = True +# ACCOUNT_AUTHENTICATION_METHOD = 'email' + +# # Automatically send confirmation email after signup +# ACCOUNT_EMAIL_CONFIRMATION_HMAC = True + + +# django-debug-toolbar +# ------------------------------------------------------------------------------ +# https://django-debug-toolbar.readthedocs.io/en/latest/installation.html#prerequisites +INSTALLED_APPS += [ + "debug_toolbar", +] # noqa F405 +# https://django-debug-toolbar.readthedocs.io/en/latest/installation.html#middleware +MIDDLEWARE += ["debug_toolbar.middleware.DebugToolbarMiddleware"] # noqa F405 +# https://django-debug-toolbar.readthedocs.io/en/latest/configuration. +# html#debug-toolbar-config + +DEBUG_TOOLBAR_PANELS = [ + # "debug_toolbar.panels.history.HistoryPanel", + "debug_toolbar.panels.versions.VersionsPanel", + "debug_toolbar.panels.timer.TimerPanel", + "debug_toolbar.panels.settings.SettingsPanel", + "debug_toolbar.panels.headers.HeadersPanel", + "debug_toolbar.panels.request.RequestPanel", + # "debug_toolbar.panels.sql.SQLPanel", + "debug_toolbar.panels.staticfiles.StaticFilesPanel", + "debug_toolbar.panels.templates.TemplatesPanel", + # "debug_toolbar.panels.cache.CachePanel", + # "debug_toolbar.panels.signals.SignalsPanel", + # "debug_toolbar.panels.logging.LoggingPanel", + # "debug_toolbar.panels.redirects.RedirectsPanel", + # "debug_toolbar.panels.profiling.ProfilingPanel", +] + +DEBUG_TOOLBAR_CONFIG = { + # "DISABLE_PANELS": ["debug_toolbar.panels.redirects.RedirectsPanel"], + "SHOW_TEMPLATE_CONTEXT": True, +} +# https://django-debug-toolbar.readthedocs.io/en/latest/installation.html#internal-ips +INTERNAL_IPS = ["127.0.0.1", "10.0.2.2"] + + +# browser reload +INSTALLED_APPS += [ + "django_browser_reload", +] + +MIDDLEWARE += ["django_browser_reload.middleware.BrowserReloadMiddleware"] + +NPM_BIN_PATH = "/usr/bin/npm" + +# django-extensions +# ------------------------------------------------------------------------------ +# https://django-extensions.readthedocs.io/en/latest/installation_instructions.html#configuration +# INSTALLED_APPS += ["django_extensions"] # noqa F405 + +# Your stuff... +# ------------------------------------------------------------------------------ +CORS_ORIGIN_WHITELIST = [ + "http://localhost:8081", + "http://127.0.0.1:8081", + "http://localhost:8080", + "http://127.0.0.1:8080", + "https://cat.cl8.localhost", +] +CORS_ALLOW_CREDENTIALS = True + + +LOGGING = { + "version": 1, + "disable_existing_loggers": False, + "formatters": {"rich": {"datefmt": "[%X]"}}, + "handlers": { + "console": { + "class": "rich.logging.RichHandler", + "formatter": "rich", + "level": "DEBUG", + } + }, + "loggers": {"django": {"handlers": ["console"]}}, +} diff --git a/justfile b/justfile index 5b1bf2d..0be581c 100644 --- a/justfile +++ b/justfile @@ -1,49 +1,49 @@ -# just files are like makefiles but a bit -# more intuitive to use -# https://github.com/casey/just - - -@test *options: - pipenv run pytest {{options}} - -@install: - #!/usr/bin/env sh - - pipenv install --dev - cd theme/static_src/ && npm install && cd ../.. - pipenv run ./manage.py migrate - pipenv run ./manage.py collectstatic --no-input - -@ci: - pipenv run pytest - -@fetch-files-from-s3: - pipenv run bash ./scripts/fetch_media_file_from_s3.sh - -@serve *options: - pipenv run ./manage.py runserver {{options}} - -@manage *options: - pipenv run ./manage.py {{options}} - -@tailwind-dev: - pipenv run ./manage.py tailwind start - -@tailwind-build: - pipenv run ./manage.py tailwind build - -@run *options: - # run gunicorn in production - pipenv run gunicorn config.wsgi --bind :8000 --workers 2 {{options}} - # pipenv run gunicorn config.wsgi -b :9000 --timeout 300 {{options}} - -@docker-build: - # create a docker image, tagged as cl8 - docker build . -t cl8 - -@docker-run: - # run the current local docker image tagged as cl8, using the env file at .env - docker run --env-file .env -p 8000:8000 -p 5432:5432 cl8 - -@caddy: +# just files are like makefiles but a bit +# more intuitive to use +# https://github.com/casey/just + + +@test *options: + pipenv run pytest {{options}} + +@install: + #!/usr/bin/env sh + + pipenv install --dev + cd theme/static_src/ && npm install && cd ../.. + pipenv run python3 manage.py migrate + pipenv run python3 manage.py collectstatic --no-input + +@ci: + pipenv run pytest + +@fetch-files-from-s3: + pipenv run bash ./scripts/fetch_media_file_from_s3.sh + +@serve *options: + pipenv run python3 manage.py runserver {{options}} + +@manage *options: + pipenv run python3 manage.py {{options}} + +@tailwind-dev: + pipenv run python3 manage.py tailwind start + +@tailwind-build: + pipenv run python3 manage.py tailwind build + +@run *options: + # run gunicorn in production + pipenv run gunicorn config.wsgi --bind :8000 --workers 2 {{options}} + # pipenv run gunicorn config.wsgi -b :9000 --timeout 300 {{options}} + +@docker-build: + # create a docker image, tagged as cl8 + docker build . -t cl8 + +@docker-run: + # run the current local docker image tagged as cl8, using the env file at .env + docker run --env-file .env -p 8000:8000 -p 5432:5432 cl8 + +@caddy: caddy run \ No newline at end of file diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..40320c6 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,2010 @@ +{ + "name": "cl8", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "cl8", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "mjml": "^4.15.3" + } + }, + "node_modules/@babel/runtime": { + "version": "7.25.4", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.25.4.tgz", + "integrity": "sha512-DSgLeL/FNcpXuzav5wfYvHCGvynXkJbn3Zvc3823AEe9nPwW9IK4UoCSS5yGymmQzN0pCPvivtgS6/8U2kkm1w==", + "license": "MIT", + "dependencies": { + "regenerator-runtime": "^0.14.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@one-ini/wasm": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@one-ini/wasm/-/wasm-0.1.1.tgz", + "integrity": "sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==", + "license": "MIT" + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/abbrev": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-2.0.0.tgz", + "integrity": "sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==", + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/ansi-colors": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", + "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/ansi-regex": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz", + "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "license": "ISC" + }, + "node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/camel-case": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/camel-case/-/camel-case-3.0.0.tgz", + "integrity": "sha512-+MbKztAYHXPr1jNTSKQF52VpcFjwY5RkR7fxksV8Doo4KAYc5Fl4UJRgthBbTmEx8C54DqahhbLJkDwjI3PI/w==", + "license": "MIT", + "dependencies": { + "no-case": "^2.2.0", + "upper-case": "^1.1.1" + } + }, + "node_modules/cheerio": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.0.0-rc.12.tgz", + "integrity": "sha512-VqR8m68vM46BNnuZ5NtnGBKIE/DfN0cRIzg9n40EIq9NOv90ayxLBXA8fXC5gquFRGJSTRqBq25Jt2ECLR431Q==", + "license": "MIT", + "dependencies": { + "cheerio-select": "^2.1.0", + "dom-serializer": "^2.0.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1", + "htmlparser2": "^8.0.1", + "parse5": "^7.0.0", + "parse5-htmlparser2-tree-adapter": "^7.0.0" + }, + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/cheeriojs/cheerio?sponsor=1" + } + }, + "node_modules/cheerio-select": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-2.1.0.tgz", + "integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-select": "^5.1.0", + "css-what": "^6.1.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/clean-css": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/clean-css/-/clean-css-4.2.4.tgz", + "integrity": "sha512-EJUDT7nDVFDvaQgAo2G/PJvxmp1o/c6iXLbswsBbUFXi1Nr+AjA2cKmfbKDMjMvzEe75g3P6JkaDDAKk96A85A==", + "license": "MIT", + "dependencies": { + "source-map": "~0.6.0" + }, + "engines": { + "node": ">= 4.0" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/cliui/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/cliui/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/cliui/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "license": "MIT" + }, + "node_modules/config-chain": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.13.tgz", + "integrity": "sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==", + "license": "MIT", + "dependencies": { + "ini": "^1.3.4", + "proto-list": "~1.2.1" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", + "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/css-select": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.1.0.tgz", + "integrity": "sha512-nwoRF1rvRRnnCqqY7updORDsuqKzqYJ28+oSMaJMMgOauh3fvwHqMS7EZpIPqK8GL+g9mKxF1vP/ZjSeNjEVHg==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-what": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.1.0.tgz", + "integrity": "sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/detect-node": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", + "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==", + "license": "MIT" + }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.1.0.tgz", + "integrity": "sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==", + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "license": "MIT" + }, + "node_modules/editorconfig": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/editorconfig/-/editorconfig-1.0.4.tgz", + "integrity": "sha512-L9Qe08KWTlqYMVvMcTIvMAdl1cDUubzRNYL+WfA4bLDMHe4nemKkpmYzkznE1FwLKu0EEmy6obgQKzMJrg4x9Q==", + "license": "MIT", + "dependencies": { + "@one-ini/wasm": "0.1.1", + "commander": "^10.0.0", + "minimatch": "9.0.1", + "semver": "^7.5.3" + }, + "bin": { + "editorconfig": "bin/editorconfig" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/editorconfig/node_modules/commander": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz", + "integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/editorconfig/node_modules/minimatch": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.1.tgz", + "integrity": "sha512-0jWhJpD/MdhPXwPuiRkCbfYfSKp2qnn2eOc279qI7f+osl/l+prKSrvhg157zSYvx/1nmgn2NqdT6k2Z7zSH9w==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "license": "MIT" + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/escalade": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.2.tgz", + "integrity": "sha512-ErCHMCae19vR8vQGe50xIsVomy19rg6gFu3+r3jkEO46suLMWBksvVyoGgQV+jOfl84ZSOSlmv6Gxa89PmTGmA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-goat": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/escape-goat/-/escape-goat-3.0.0.tgz", + "integrity": "sha512-w3PwNZJwRxlp47QGzhuEBldEqVHHhh8/tIPcl6ecf2Bou99cdAt0knihBV0Ecc7CGxYduXVBDheH1K2oADRlvw==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/foreground-child": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.0.tgz", + "integrity": "sha512-Ld2g8rrAyMYFXBhEqMz8ZAHBi4J4uS1i/CxGMDnjyFWddMXLVcDp051DZfu+t7+ab7Wv6SMqpWmyFIj5UbfFvg==", + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.0", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/glob": { + "version": "10.4.5", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", + "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, + "node_modules/html-minifier": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-minifier/-/html-minifier-4.0.0.tgz", + "integrity": "sha512-aoGxanpFPLg7MkIl/DDFYtb0iWz7jMFGqFhvEDZga6/4QTjneiD8I/NXL1x5aaoCp7FSIT6h/OhykDdPsbtMig==", + "license": "MIT", + "dependencies": { + "camel-case": "^3.0.0", + "clean-css": "^4.2.1", + "commander": "^2.19.0", + "he": "^1.2.0", + "param-case": "^2.1.1", + "relateurl": "^0.2.7", + "uglify-js": "^3.5.1" + }, + "bin": { + "html-minifier": "cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/htmlparser2": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz", + "integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1", + "entities": "^4.4.0" + } + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/js-beautify": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/js-beautify/-/js-beautify-1.15.1.tgz", + "integrity": "sha512-ESjNzSlt/sWE8sciZH8kBF8BPlwXPwhR6pWKAw8bw4Bwj+iZcnKW6ONWUutJ7eObuBZQpiIb8S7OYspWrKt7rA==", + "license": "MIT", + "dependencies": { + "config-chain": "^1.1.13", + "editorconfig": "^1.0.4", + "glob": "^10.3.3", + "js-cookie": "^3.0.5", + "nopt": "^7.2.0" + }, + "bin": { + "css-beautify": "js/bin/css-beautify.js", + "html-beautify": "js/bin/html-beautify.js", + "js-beautify": "js/bin/js-beautify.js" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/js-cookie": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz", + "integrity": "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/juice": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/juice/-/juice-10.0.1.tgz", + "integrity": "sha512-ZhJT1soxJCkOiO55/mz8yeBKTAJhRzX9WBO+16ZTqNTONnnVlUPyVBIzQ7lDRjaBdTbid+bAnyIon/GM3yp4cA==", + "license": "MIT", + "dependencies": { + "cheerio": "1.0.0-rc.12", + "commander": "^6.1.0", + "mensch": "^0.3.4", + "slick": "^1.12.2", + "web-resource-inliner": "^6.0.1" + }, + "bin": { + "juice": "bin/juice" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/juice/node_modules/commander": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-6.2.1.tgz", + "integrity": "sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "license": "MIT" + }, + "node_modules/lower-case": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/lower-case/-/lower-case-1.1.4.tgz", + "integrity": "sha512-2Fgx1Ycm599x+WGpIYwJOvsjmXFzTSc34IwDWALRA/8AopUKAVPwfJ+h5+f85BCp0PWmmJcWzEpxOpoXycMpdA==", + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "license": "ISC" + }, + "node_modules/mensch": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/mensch/-/mensch-0.3.4.tgz", + "integrity": "sha512-IAeFvcOnV9V0Yk+bFhYR07O3yNina9ANIN5MoXBKYJ/RLYPurd2d0yw14MDhpr9/momp0WofT1bPUh3hkzdi/g==", + "license": "MIT" + }, + "node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/mjml": { + "version": "4.15.3", + "resolved": "https://registry.npmjs.org/mjml/-/mjml-4.15.3.tgz", + "integrity": "sha512-bW2WpJxm6HS+S3Yu6tq1DUPFoTxU9sPviUSmnL7Ua+oVO3WA5ILFWqvujUlz+oeuM+HCwEyMiP5xvKNPENVjYA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.9", + "mjml-cli": "4.15.3", + "mjml-core": "4.15.3", + "mjml-migrate": "4.15.3", + "mjml-preset-core": "4.15.3", + "mjml-validator": "4.15.3" + }, + "bin": { + "mjml": "bin/mjml" + } + }, + "node_modules/mjml-accordion": { + "version": "4.15.3", + "resolved": "https://registry.npmjs.org/mjml-accordion/-/mjml-accordion-4.15.3.tgz", + "integrity": "sha512-LPNVSj1LyUVYT9G1gWwSw3GSuDzDsQCu0tPB2uDsq4VesYNnU6v3iLCQidMiR6azmIt13OEozG700ygAUuA6Ng==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.9", + "lodash": "^4.17.21", + "mjml-core": "4.15.3" + } + }, + "node_modules/mjml-body": { + "version": "4.15.3", + "resolved": "https://registry.npmjs.org/mjml-body/-/mjml-body-4.15.3.tgz", + "integrity": "sha512-7pfUOVPtmb0wC+oUOn4xBsAw4eT5DyD6xqaxj/kssu6RrFXOXgJaVnDPAI9AzIvXJ/5as9QrqRGYAddehwWpHQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.9", + "lodash": "^4.17.21", + "mjml-core": "4.15.3" + } + }, + "node_modules/mjml-button": { + "version": "4.15.3", + "resolved": "https://registry.npmjs.org/mjml-button/-/mjml-button-4.15.3.tgz", + "integrity": "sha512-79qwn9AgdGjJR1vLnrcm2rq2AsAZkKC5JPwffTMG+Nja6zGYpTDZFZ56ekHWr/r1b5WxkukcPj2PdevUug8c+Q==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.9", + "lodash": "^4.17.21", + "mjml-core": "4.15.3" + } + }, + "node_modules/mjml-carousel": { + "version": "4.15.3", + "resolved": "https://registry.npmjs.org/mjml-carousel/-/mjml-carousel-4.15.3.tgz", + "integrity": "sha512-3ju6I4l7uUhPRrJfN3yK9AMsfHvrYbRkcJ1GRphFHzUj37B2J6qJOQUpzA547Y4aeh69TSb7HFVf1t12ejQxVw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.9", + "lodash": "^4.17.21", + "mjml-core": "4.15.3" + } + }, + "node_modules/mjml-cli": { + "version": "4.15.3", + "resolved": "https://registry.npmjs.org/mjml-cli/-/mjml-cli-4.15.3.tgz", + "integrity": "sha512-+V2TDw3tXUVEptFvLSerz125C2ogYl8klIBRY1m5BHd4JvGVf3yhx8N3PngByCzA6PGcv/eydGQN+wy34SHf0Q==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.9", + "chokidar": "^3.0.0", + "glob": "^10.3.10", + "html-minifier": "^4.0.0", + "js-beautify": "^1.6.14", + "lodash": "^4.17.21", + "minimatch": "^9.0.3", + "mjml-core": "4.15.3", + "mjml-migrate": "4.15.3", + "mjml-parser-xml": "4.15.3", + "mjml-validator": "4.15.3", + "yargs": "^17.7.2" + }, + "bin": { + "mjml-cli": "bin/mjml" + } + }, + "node_modules/mjml-column": { + "version": "4.15.3", + "resolved": "https://registry.npmjs.org/mjml-column/-/mjml-column-4.15.3.tgz", + "integrity": "sha512-hYdEFdJGHPbZJSEysykrevEbB07yhJGSwfDZEYDSbhQQFjV2tXrEgYcFD5EneMaowjb55e3divSJxU4c5q4Qgw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.9", + "lodash": "^4.17.21", + "mjml-core": "4.15.3" + } + }, + "node_modules/mjml-core": { + "version": "4.15.3", + "resolved": "https://registry.npmjs.org/mjml-core/-/mjml-core-4.15.3.tgz", + "integrity": "sha512-Dmwk+2cgSD9L9GmTbEUNd8QxkTZtW9P7FN/ROZW/fGZD6Hq6/4TB0zEspg2Ow9eYjZXO2ofOJ3PaQEEShKV0kQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.9", + "cheerio": "1.0.0-rc.12", + "detect-node": "^2.0.4", + "html-minifier": "^4.0.0", + "js-beautify": "^1.6.14", + "juice": "^10.0.0", + "lodash": "^4.17.21", + "mjml-migrate": "4.15.3", + "mjml-parser-xml": "4.15.3", + "mjml-validator": "4.15.3" + } + }, + "node_modules/mjml-divider": { + "version": "4.15.3", + "resolved": "https://registry.npmjs.org/mjml-divider/-/mjml-divider-4.15.3.tgz", + "integrity": "sha512-vh27LQ9FG/01y0b9ntfqm+GT5AjJnDSDY9hilss2ixIUh0FemvfGRfsGVeV5UBVPBKK7Ffhvfqc7Rciob9Spzw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.9", + "lodash": "^4.17.21", + "mjml-core": "4.15.3" + } + }, + "node_modules/mjml-group": { + "version": "4.15.3", + "resolved": "https://registry.npmjs.org/mjml-group/-/mjml-group-4.15.3.tgz", + "integrity": "sha512-HSu/rKnGZVKFq3ciT46vi1EOy+9mkB0HewO4+P6dP/Y0UerWkN6S3UK11Cxsj0cAp0vFwkPDCdOeEzRdpFEkzA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.9", + "lodash": "^4.17.21", + "mjml-core": "4.15.3" + } + }, + "node_modules/mjml-head": { + "version": "4.15.3", + "resolved": "https://registry.npmjs.org/mjml-head/-/mjml-head-4.15.3.tgz", + "integrity": "sha512-o3mRuuP/MB5fZycjD3KH/uXsnaPl7Oo8GtdbJTKtH1+O/3pz8GzGMkscTKa97l03DAG2EhGrzzLcU2A6eshwFw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.9", + "lodash": "^4.17.21", + "mjml-core": "4.15.3" + } + }, + "node_modules/mjml-head-attributes": { + "version": "4.15.3", + "resolved": "https://registry.npmjs.org/mjml-head-attributes/-/mjml-head-attributes-4.15.3.tgz", + "integrity": "sha512-2ISo0r5ZKwkrvJgDou9xVPxxtXMaETe2AsAA02L89LnbB2KC0N5myNsHV0sEysTw9+CfCmgjAb0GAI5QGpxKkQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.9", + "lodash": "^4.17.21", + "mjml-core": "4.15.3" + } + }, + "node_modules/mjml-head-breakpoint": { + "version": "4.15.3", + "resolved": "https://registry.npmjs.org/mjml-head-breakpoint/-/mjml-head-breakpoint-4.15.3.tgz", + "integrity": "sha512-Eo56FA5C2v6ucmWQL/JBJ2z641pLOom4k0wP6CMZI2utfyiJ+e2Uuinj1KTrgDcEvW4EtU9HrfAqLK9UosLZlg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.9", + "lodash": "^4.17.21", + "mjml-core": "4.15.3" + } + }, + "node_modules/mjml-head-font": { + "version": "4.15.3", + "resolved": "https://registry.npmjs.org/mjml-head-font/-/mjml-head-font-4.15.3.tgz", + "integrity": "sha512-CzV2aDPpiNIIgGPHNcBhgyedKY4SX3BJoTwOobSwZVIlEA6TAWB4Z9WwFUmQqZOgo1AkkiTHPZQvGcEhFFXH6g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.9", + "lodash": "^4.17.21", + "mjml-core": "4.15.3" + } + }, + "node_modules/mjml-head-html-attributes": { + "version": "4.15.3", + "resolved": "https://registry.npmjs.org/mjml-head-html-attributes/-/mjml-head-html-attributes-4.15.3.tgz", + "integrity": "sha512-MDNDPMBOgXUZYdxhosyrA2kudiGO8aogT0/cODyi2Ed9o/1S7W+je11JUYskQbncqhWKGxNyaP4VWa+6+vUC/g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.9", + "lodash": "^4.17.21", + "mjml-core": "4.15.3" + } + }, + "node_modules/mjml-head-preview": { + "version": "4.15.3", + "resolved": "https://registry.npmjs.org/mjml-head-preview/-/mjml-head-preview-4.15.3.tgz", + "integrity": "sha512-J2PxCefUVeFwsAExhrKo4lwxDevc5aKj888HBl/wN4EuWOoOg06iOGCxz4Omd8dqyFsrqvbBuPqRzQ+VycGmaA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.9", + "lodash": "^4.17.21", + "mjml-core": "4.15.3" + } + }, + "node_modules/mjml-head-style": { + "version": "4.15.3", + "resolved": "https://registry.npmjs.org/mjml-head-style/-/mjml-head-style-4.15.3.tgz", + "integrity": "sha512-9J+JuH+mKrQU65CaJ4KZegACUgNIlYmWQYx3VOBR/tyz+8kDYX7xBhKJCjQ1I4wj2Tvga3bykd89Oc2kFZ5WOw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.9", + "lodash": "^4.17.21", + "mjml-core": "4.15.3" + } + }, + "node_modules/mjml-head-title": { + "version": "4.15.3", + "resolved": "https://registry.npmjs.org/mjml-head-title/-/mjml-head-title-4.15.3.tgz", + "integrity": "sha512-IM59xRtsxID4DubQ0iLmoCGXguEe+9BFG4z6y2xQDrscIa4QY3KlfqgKGT69ojW+AVbXXJPEVqrAi4/eCsLItQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.9", + "lodash": "^4.17.21", + "mjml-core": "4.15.3" + } + }, + "node_modules/mjml-hero": { + "version": "4.15.3", + "resolved": "https://registry.npmjs.org/mjml-hero/-/mjml-hero-4.15.3.tgz", + "integrity": "sha512-9cLAPuc69yiuzNrMZIN58j+HMK1UWPaq2i3/Fg2ZpimfcGFKRcPGCbEVh0v+Pb6/J0+kf8yIO0leH20opu3AyQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.9", + "lodash": "^4.17.21", + "mjml-core": "4.15.3" + } + }, + "node_modules/mjml-image": { + "version": "4.15.3", + "resolved": "https://registry.npmjs.org/mjml-image/-/mjml-image-4.15.3.tgz", + "integrity": "sha512-g1OhSdofIytE9qaOGdTPmRIp7JsCtgO0zbsn1Fk6wQh2gEL55Z40j/VoghslWAWTgT2OHFdBKnMvWtN6U5+d2Q==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.9", + "lodash": "^4.17.21", + "mjml-core": "4.15.3" + } + }, + "node_modules/mjml-migrate": { + "version": "4.15.3", + "resolved": "https://registry.npmjs.org/mjml-migrate/-/mjml-migrate-4.15.3.tgz", + "integrity": "sha512-sr/+35RdxZroNQVegjpfRHJ5hda9XCgaS4mK2FGO+Mb1IUevKfeEPII3F/cHDpNwFeYH3kAgyqQ22ClhGLWNBA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.9", + "js-beautify": "^1.6.14", + "lodash": "^4.17.21", + "mjml-core": "4.15.3", + "mjml-parser-xml": "4.15.3", + "yargs": "^17.7.2" + }, + "bin": { + "migrate": "lib/cli.js" + } + }, + "node_modules/mjml-navbar": { + "version": "4.15.3", + "resolved": "https://registry.npmjs.org/mjml-navbar/-/mjml-navbar-4.15.3.tgz", + "integrity": "sha512-VsKH/Jdlf8Yu3y7GpzQV5n7JMdpqvZvTSpF6UQXL0PWOm7k6+LX+sCZimOfpHJ+wCaaybpxokjWZ71mxOoCWoA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.9", + "lodash": "^4.17.21", + "mjml-core": "4.15.3" + } + }, + "node_modules/mjml-parser-xml": { + "version": "4.15.3", + "resolved": "https://registry.npmjs.org/mjml-parser-xml/-/mjml-parser-xml-4.15.3.tgz", + "integrity": "sha512-Tz0UX8/JVYICLjT+U8J1f/TFxIYVYjzZHeh4/Oyta0pLpRLeZlxEd71f3u3kdnulCKMP4i37pFRDmyLXAlEuLw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.9", + "detect-node": "2.1.0", + "htmlparser2": "^9.1.0", + "lodash": "^4.17.15" + } + }, + "node_modules/mjml-parser-xml/node_modules/htmlparser2": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-9.1.0.tgz", + "integrity": "sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.1.0", + "entities": "^4.5.0" + } + }, + "node_modules/mjml-preset-core": { + "version": "4.15.3", + "resolved": "https://registry.npmjs.org/mjml-preset-core/-/mjml-preset-core-4.15.3.tgz", + "integrity": "sha512-1zZS8P4O0KweWUqNS655+oNnVMPQ1Rq1GaZq5S9JfwT1Vh/m516lSmiTW9oko6gGHytt5s6Yj6oOeu5Zm8FoLw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.9", + "mjml-accordion": "4.15.3", + "mjml-body": "4.15.3", + "mjml-button": "4.15.3", + "mjml-carousel": "4.15.3", + "mjml-column": "4.15.3", + "mjml-divider": "4.15.3", + "mjml-group": "4.15.3", + "mjml-head": "4.15.3", + "mjml-head-attributes": "4.15.3", + "mjml-head-breakpoint": "4.15.3", + "mjml-head-font": "4.15.3", + "mjml-head-html-attributes": "4.15.3", + "mjml-head-preview": "4.15.3", + "mjml-head-style": "4.15.3", + "mjml-head-title": "4.15.3", + "mjml-hero": "4.15.3", + "mjml-image": "4.15.3", + "mjml-navbar": "4.15.3", + "mjml-raw": "4.15.3", + "mjml-section": "4.15.3", + "mjml-social": "4.15.3", + "mjml-spacer": "4.15.3", + "mjml-table": "4.15.3", + "mjml-text": "4.15.3", + "mjml-wrapper": "4.15.3" + } + }, + "node_modules/mjml-raw": { + "version": "4.15.3", + "resolved": "https://registry.npmjs.org/mjml-raw/-/mjml-raw-4.15.3.tgz", + "integrity": "sha512-IGyHheOYyRchBLiAEgw3UM11kFNmBSMupu2BDdejC6ZiDhEAdG+tyERlsCwDPYtXanvFpGWULIu3XlsUPc+RZw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.9", + "lodash": "^4.17.21", + "mjml-core": "4.15.3" + } + }, + "node_modules/mjml-section": { + "version": "4.15.3", + "resolved": "https://registry.npmjs.org/mjml-section/-/mjml-section-4.15.3.tgz", + "integrity": "sha512-JfVPRXH++Hd933gmQfG8JXXCBCR6fIzC3DwiYycvanL/aW1cEQ2EnebUfQkt5QzlYjOkJEH+JpccAsq3ln6FZQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.9", + "lodash": "^4.17.21", + "mjml-core": "4.15.3" + } + }, + "node_modules/mjml-social": { + "version": "4.15.3", + "resolved": "https://registry.npmjs.org/mjml-social/-/mjml-social-4.15.3.tgz", + "integrity": "sha512-7sD5FXrESOxpT9Z4Oh36bS6u/geuUrMP1aCg2sjyAwbPcF1aWa2k9OcatQfpRf6pJEhUZ18y6/WBBXmMVmSzXg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.9", + "lodash": "^4.17.21", + "mjml-core": "4.15.3" + } + }, + "node_modules/mjml-spacer": { + "version": "4.15.3", + "resolved": "https://registry.npmjs.org/mjml-spacer/-/mjml-spacer-4.15.3.tgz", + "integrity": "sha512-3B7Qj+17EgDdAtZ3NAdMyOwLTX1jfmJuY7gjyhS2HtcZAmppW+cxqHUBwCKfvSRgTQiccmEvtNxaQK+tfyrZqA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.9", + "lodash": "^4.17.21", + "mjml-core": "4.15.3" + } + }, + "node_modules/mjml-table": { + "version": "4.15.3", + "resolved": "https://registry.npmjs.org/mjml-table/-/mjml-table-4.15.3.tgz", + "integrity": "sha512-FLx7DcRKTdKdcOCbMyBaeudeHaHpwPveRrBm6WyQe3LXx6FfdmOh59i71/16LFQMgBOD3N4/UJkzxLzlTJzMqQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.9", + "lodash": "^4.17.21", + "mjml-core": "4.15.3" + } + }, + "node_modules/mjml-text": { + "version": "4.15.3", + "resolved": "https://registry.npmjs.org/mjml-text/-/mjml-text-4.15.3.tgz", + "integrity": "sha512-+C0hxCmw9kg0XzT6vhE5mFkK6y225nC8UEQcN94K0fBCjPKkM+HqZMwGX205fzdGRi+Bxa55b/VhrIVwdv+8vw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.9", + "lodash": "^4.17.21", + "mjml-core": "4.15.3" + } + }, + "node_modules/mjml-validator": { + "version": "4.15.3", + "resolved": "https://registry.npmjs.org/mjml-validator/-/mjml-validator-4.15.3.tgz", + "integrity": "sha512-Xb72KdqRwjv/qM2rJpV22syyP2N3cRQ9VVDrN6u2FSzLq02buFNxmSPJ7CKhat3PrUNdVHU75KZwOf/tz4UEhA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.9" + } + }, + "node_modules/mjml-wrapper": { + "version": "4.15.3", + "resolved": "https://registry.npmjs.org/mjml-wrapper/-/mjml-wrapper-4.15.3.tgz", + "integrity": "sha512-ditsCijeHJrmBmObtJmQ18ddLxv5oPyMTdPU8Di8APOnD2zPk7Z4UAuJSl7HXB45oFiivr3MJf4koFzMUSZ6Gg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.9", + "lodash": "^4.17.21", + "mjml-core": "4.15.3", + "mjml-section": "4.15.3" + } + }, + "node_modules/no-case": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/no-case/-/no-case-2.3.2.tgz", + "integrity": "sha512-rmTZ9kz+f3rCvK2TD1Ue/oZlns7OGoIWP4fc3llxxRXlOkHKoWPPWJOfFYpITabSow43QJbRIoHQXtt10VldyQ==", + "license": "MIT", + "dependencies": { + "lower-case": "^1.1.1" + } + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/nopt": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-7.2.1.tgz", + "integrity": "sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w==", + "license": "ISC", + "dependencies": { + "abbrev": "^2.0.0" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.0.tgz", + "integrity": "sha512-dATvCeZN/8wQsGywez1mzHtTlP22H8OEfPrVMLNr4/eGa+ijtLn/6M5f0dY8UKNrC2O9UCU6SSoG3qRKnt7STw==", + "license": "BlueOak-1.0.0" + }, + "node_modules/param-case": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/param-case/-/param-case-2.1.1.tgz", + "integrity": "sha512-eQE845L6ot89sk2N8liD8HAuH4ca6Vvr7VWAWwt7+kvvG5aBcPmmphQ68JsEG2qa9n1TykS2DLeMt363AAH8/w==", + "license": "MIT", + "dependencies": { + "no-case": "^2.2.0" + } + }, + "node_modules/parse5": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.1.2.tgz", + "integrity": "sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw==", + "license": "MIT", + "dependencies": { + "entities": "^4.4.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-htmlparser2-tree-adapter": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.0.0.tgz", + "integrity": "sha512-B77tOZrqqfUfnVcOrUvfdLbz4pu4RopLD/4vmu3HUPswwTA8OH0EMW9BlWR2B0RCoiZRAHEUu7IxeP1Pd1UU+g==", + "license": "MIT", + "dependencies": { + "domhandler": "^5.0.2", + "parse5": "^7.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/proto-list": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz", + "integrity": "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==", + "license": "ISC" + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/regenerator-runtime": { + "version": "0.14.1", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", + "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==", + "license": "MIT" + }, + "node_modules/relateurl": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/relateurl/-/relateurl-0.2.7.tgz", + "integrity": "sha512-G08Dxvm4iDN3MLM0EsP62EDV9IuhXPR6blNz6Utcp7zyV3tr4HVNINt6MpaRWbxoOHT3Q7YN2P+jaHX8vUbgog==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/semver": { + "version": "7.6.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", + "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/slick": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/slick/-/slick-1.12.2.tgz", + "integrity": "sha512-4qdtOGcBjral6YIBCWJ0ljFSKNLz9KkhbWtuGvUyRowl1kxfuE1x/Z/aJcaiilpb3do9bl5K7/1h9XC5wWpY/A==", + "license": "MIT (http://mootools.net/license.txt)", + "engines": { + "node": "*" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/uglify-js": { + "version": "3.19.2", + "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.2.tgz", + "integrity": "sha512-S8KA6DDI47nQXJSi2ctQ629YzwOVs+bQML6DAtvy0wgNdpi+0ySpQK0g2pxBq2xfF2z3YCscu7NNA8nXT9PlIQ==", + "license": "BSD-2-Clause", + "bin": { + "uglifyjs": "bin/uglifyjs" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/upper-case": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/upper-case/-/upper-case-1.1.3.tgz", + "integrity": "sha512-WRbjgmYzgXkCV7zNVpy5YgrHgbBv126rMALQQMrmzOVC4GM2waQ9x7xtm8VU+1yF2kWyPzI9zbZ48n4vSxwfSA==", + "license": "MIT" + }, + "node_modules/valid-data-url": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/valid-data-url/-/valid-data-url-3.0.1.tgz", + "integrity": "sha512-jOWVmzVceKlVVdwjNSenT4PbGghU0SBIizAev8ofZVgivk/TVHXSbNL8LP6M3spZvkR9/QolkyJavGSX5Cs0UA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/web-resource-inliner": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/web-resource-inliner/-/web-resource-inliner-6.0.1.tgz", + "integrity": "sha512-kfqDxt5dTB1JhqsCUQVFDj0rmY+4HLwGQIsLPbyrsN9y9WV/1oFDSx3BQ4GfCv9X+jVeQ7rouTqwK53rA/7t8A==", + "license": "MIT", + "dependencies": { + "ansi-colors": "^4.1.1", + "escape-goat": "^3.0.0", + "htmlparser2": "^5.0.0", + "mime": "^2.4.6", + "node-fetch": "^2.6.0", + "valid-data-url": "^3.0.0" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/web-resource-inliner/node_modules/dom-serializer": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-1.4.1.tgz", + "integrity": "sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag==", + "license": "MIT", + "dependencies": { + "domelementtype": "^2.0.1", + "domhandler": "^4.2.0", + "entities": "^2.0.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/web-resource-inliner/node_modules/dom-serializer/node_modules/domhandler": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-4.3.1.tgz", + "integrity": "sha512-GrwoxYN+uWlzO8uhUXRl0P+kHE4GtVPfYzVLcUxPL7KNdHKj66vvlhiweIHqYYXWlw+T8iLMp42Lm67ghw4WMQ==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.2.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/web-resource-inliner/node_modules/domhandler": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-3.3.0.tgz", + "integrity": "sha512-J1C5rIANUbuYK+FuFL98650rihynUOEzRLxW+90bKZRWB6A1X1Tf82GxR1qAWLyfNPRvjqfip3Q5tdYlmAa9lA==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.0.1" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/web-resource-inliner/node_modules/domutils": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-2.8.0.tgz", + "integrity": "sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==", + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^1.0.1", + "domelementtype": "^2.2.0", + "domhandler": "^4.2.0" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/web-resource-inliner/node_modules/domutils/node_modules/domhandler": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-4.3.1.tgz", + "integrity": "sha512-GrwoxYN+uWlzO8uhUXRl0P+kHE4GtVPfYzVLcUxPL7KNdHKj66vvlhiweIHqYYXWlw+T8iLMp42Lm67ghw4WMQ==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.2.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/web-resource-inliner/node_modules/entities": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-2.2.0.tgz", + "integrity": "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==", + "license": "BSD-2-Clause", + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/web-resource-inliner/node_modules/htmlparser2": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-5.0.1.tgz", + "integrity": "sha512-vKZZra6CSe9qsJzh0BjBGXo8dvzNsq/oGvsjfRdOrrryfeD9UOBEEQdeoqCRmKZchF5h2zOBMQ6YuQ0uRUmdbQ==", + "license": "MIT", + "dependencies": { + "domelementtype": "^2.0.1", + "domhandler": "^3.3.0", + "domutils": "^2.4.2", + "entities": "^2.0.0" + }, + "funding": { + "url": "https://github.com/fb55/htmlparser2?sponsor=1" + } + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/yargs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..6b6de4c --- /dev/null +++ b/package.json @@ -0,0 +1,18 @@ +{ + "name": "cl8", + "version": "1.0.0", + "description": "Constellate is social software designed to help communities of practice and other small-ish groups to discover skills and interests inside the peer group.", + "main": "index.js", + "directories": { + "doc": "docs" + }, + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [], + "author": "", + "license": "ISC", + "dependencies": { + "mjml": "^4.15.3" + } +} diff --git a/theme/templates/base.html b/theme/templates/base.html index 3e33051..590fedb 100644 --- a/theme/templates/base.html +++ b/theme/templates/base.html @@ -1,62 +1,66 @@ -{% load static tailwind_tags %} - - - - {{ constellation.site.name }} - - - - {% tailwind_css %} - - {% if request.constellation.favicon %} - - {% else %} - - {% endif %} - {% comment %} -

- Breakpoint: - mobile - - - - - - - -

- {% endcomment %} - -

- {% block nav %} - {% include "_nav_menu.html" %} - {% endblock nav %} -
-
- {% block content %} -
-
-
- {% block inner %} -

Django + Tailwind = ❤️

- {% endblock inner %} -
-
-
- {% endblock content %} - - {% block scripts %} - {% endblock scripts %} - - +{% load static tailwind_tags %} + + + + {{ constellation.site.name }} + + + + {% tailwind_css %} + + + {% if request.constellation.favicon %} + + {% else %} + + {% endif %} + {% comment %} +

+ Breakpoint: + mobile + + + + + + + +

+ {% endcomment %} + +

+ {% block nav %} + {% include "_nav_menu.html" %} + {% endblock nav %} +
+
+ {% block content %} +
+
+
+ {% block inner %} +

Django + Tailwind = ❤️

+ {% endblock inner %} +
+
+
+ {% endblock content %} +
+
+ +
+
+ {% block scripts %} + {% endblock scripts %} + + +