diff options
author | Matthew Lemon <y@yulqen.org> | 2024-05-13 17:26:25 +0100 |
---|---|---|
committer | Matthew Lemon <y@yulqen.org> | 2024-05-13 17:26:25 +0100 |
commit | efbbd480ddc62e695123d31c31d233b0df5155bd (patch) | |
tree | bc2fb465edd5050d83c97f280b1aac8e023fe3e5 /pyblackbird_cc |
After first pre-commit processing
Diffstat (limited to 'pyblackbird_cc')
64 files changed, 1870 insertions, 0 deletions
diff --git a/pyblackbird_cc/__init__.py b/pyblackbird_cc/__init__.py new file mode 100644 index 0000000..3da9e5f --- /dev/null +++ b/pyblackbird_cc/__init__.py @@ -0,0 +1,5 @@ +__version__ = "0.1.0" +__version_info__ = tuple( + int(num) if num.isdigit() else num + for num in __version__.replace("-", ".", 1).split(".") +) diff --git a/pyblackbird_cc/conftest.py b/pyblackbird_cc/conftest.py new file mode 100644 index 0000000..966a3e5 --- /dev/null +++ b/pyblackbird_cc/conftest.py @@ -0,0 +1,14 @@ +import pytest + +from pyblackbird_cc.users.models import User +from pyblackbird_cc.users.tests.factories import UserFactory + + +@pytest.fixture(autouse=True) +def _media_storage(settings, tmpdir) -> None: + settings.MEDIA_ROOT = tmpdir.strpath + + +@pytest.fixture() +def user(db) -> User: + return UserFactory() diff --git a/pyblackbird_cc/contrib/__init__.py b/pyblackbird_cc/contrib/__init__.py new file mode 100644 index 0000000..1c7ecc8 --- /dev/null +++ b/pyblackbird_cc/contrib/__init__.py @@ -0,0 +1,5 @@ +""" +To understand why this file is here, please read: + +http://cookiecutter-django.readthedocs.io/en/latest/faq.html#why-is-there-a-django-contrib-sites-directory-in-cookiecutter-django +""" diff --git a/pyblackbird_cc/contrib/sites/__init__.py b/pyblackbird_cc/contrib/sites/__init__.py new file mode 100644 index 0000000..1c7ecc8 --- /dev/null +++ b/pyblackbird_cc/contrib/sites/__init__.py @@ -0,0 +1,5 @@ +""" +To understand why this file is here, please read: + +http://cookiecutter-django.readthedocs.io/en/latest/faq.html#why-is-there-a-django-contrib-sites-directory-in-cookiecutter-django +""" diff --git a/pyblackbird_cc/contrib/sites/migrations/0001_initial.py b/pyblackbird_cc/contrib/sites/migrations/0001_initial.py new file mode 100644 index 0000000..fd76afb --- /dev/null +++ b/pyblackbird_cc/contrib/sites/migrations/0001_initial.py @@ -0,0 +1,43 @@ +import django.contrib.sites.models +from django.contrib.sites.models import _simple_domain_name_validator +from django.db import migrations +from django.db import models + + +class Migration(migrations.Migration): + + dependencies = [] + + operations = [ + migrations.CreateModel( + name="Site", + fields=[ + ( + "id", + models.AutoField( + verbose_name="ID", + serialize=False, + auto_created=True, + primary_key=True, + ), + ), + ( + "domain", + models.CharField( + max_length=100, + verbose_name="domain name", + validators=[_simple_domain_name_validator], + ), + ), + ("name", models.CharField(max_length=50, verbose_name="display name")), + ], + options={ + "ordering": ("domain",), + "db_table": "django_site", + "verbose_name": "site", + "verbose_name_plural": "sites", + }, + bases=(models.Model,), + managers=[("objects", django.contrib.sites.models.SiteManager())], + ), + ] diff --git a/pyblackbird_cc/contrib/sites/migrations/0002_alter_domain_unique.py b/pyblackbird_cc/contrib/sites/migrations/0002_alter_domain_unique.py new file mode 100644 index 0000000..4a44a6a --- /dev/null +++ b/pyblackbird_cc/contrib/sites/migrations/0002_alter_domain_unique.py @@ -0,0 +1,21 @@ +import django.contrib.sites.models +from django.db import migrations +from django.db import models + + +class Migration(migrations.Migration): + + dependencies = [("sites", "0001_initial")] + + operations = [ + migrations.AlterField( + model_name="site", + name="domain", + field=models.CharField( + max_length=100, + unique=True, + validators=[django.contrib.sites.models._simple_domain_name_validator], + verbose_name="domain name", + ), + ) + ] diff --git a/pyblackbird_cc/contrib/sites/migrations/0003_set_site_domain_and_name.py b/pyblackbird_cc/contrib/sites/migrations/0003_set_site_domain_and_name.py new file mode 100644 index 0000000..f4b1cbb --- /dev/null +++ b/pyblackbird_cc/contrib/sites/migrations/0003_set_site_domain_and_name.py @@ -0,0 +1,63 @@ +""" +To understand why this file is here, please read: + +http://cookiecutter-django.readthedocs.io/en/latest/faq.html#why-is-there-a-django-contrib-sites-directory-in-cookiecutter-django +""" +from django.conf import settings +from django.db import migrations + + +def _update_or_create_site_with_sequence(site_model, connection, domain, name): + """Update or create the site with default ID and keep the DB sequence in sync.""" + site, created = site_model.objects.update_or_create( + id=settings.SITE_ID, + defaults={ + "domain": domain, + "name": name, + }, + ) + if created: + # We provided the ID explicitly when creating the Site entry, therefore the DB + # sequence to auto-generate them wasn't used and is now out of sync. If we + # don't do anything, we'll get a unique constraint violation the next time a + # site is created. + # To avoid this, we need to manually update DB sequence and make sure it's + # greater than the maximum value. + max_id = site_model.objects.order_by("-id").first().id + with connection.cursor() as cursor: + cursor.execute("SELECT last_value from django_site_id_seq") + (current_id,) = cursor.fetchone() + if current_id <= max_id: + cursor.execute( + "alter sequence django_site_id_seq restart with %s", + [max_id + 1], + ) + + +def update_site_forward(apps, schema_editor): + """Set site domain and name.""" + Site = apps.get_model("sites", "Site") + _update_or_create_site_with_sequence( + Site, + schema_editor.connection, + "resources.joannalemon.com", + "pyblackbird-cc", + ) + + +def update_site_backward(apps, schema_editor): + """Revert site domain and name to default.""" + Site = apps.get_model("sites", "Site") + _update_or_create_site_with_sequence( + Site, + schema_editor.connection, + "example.com", + "example.com", + ) + + +class Migration(migrations.Migration): + + dependencies = [("sites", "0002_alter_domain_unique")] + + operations = [migrations.RunPython(update_site_forward, update_site_backward)] diff --git a/pyblackbird_cc/contrib/sites/migrations/0004_alter_options_ordering_domain.py b/pyblackbird_cc/contrib/sites/migrations/0004_alter_options_ordering_domain.py new file mode 100644 index 0000000..f7118ca --- /dev/null +++ b/pyblackbird_cc/contrib/sites/migrations/0004_alter_options_ordering_domain.py @@ -0,0 +1,21 @@ +# Generated by Django 3.1.7 on 2021-02-04 14:49 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("sites", "0003_set_site_domain_and_name"), + ] + + operations = [ + migrations.AlterModelOptions( + name="site", + options={ + "ordering": ["domain"], + "verbose_name": "site", + "verbose_name_plural": "sites", + }, + ), + ] diff --git a/pyblackbird_cc/contrib/sites/migrations/__init__.py b/pyblackbird_cc/contrib/sites/migrations/__init__.py new file mode 100644 index 0000000..1c7ecc8 --- /dev/null +++ b/pyblackbird_cc/contrib/sites/migrations/__init__.py @@ -0,0 +1,5 @@ +""" +To understand why this file is here, please read: + +http://cookiecutter-django.readthedocs.io/en/latest/faq.html#why-is-there-a-django-contrib-sites-directory-in-cookiecutter-django +""" diff --git a/pyblackbird_cc/resources/__init__.py b/pyblackbird_cc/resources/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/pyblackbird_cc/resources/__init__.py diff --git a/pyblackbird_cc/resources/admin.py b/pyblackbird_cc/resources/admin.py new file mode 100644 index 0000000..846f6b4 --- /dev/null +++ b/pyblackbird_cc/resources/admin.py @@ -0,0 +1 @@ +# Register your models here. diff --git a/pyblackbird_cc/resources/apps.py b/pyblackbird_cc/resources/apps.py new file mode 100644 index 0000000..2756fc0 --- /dev/null +++ b/pyblackbird_cc/resources/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class ResourcesConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "pyblackbird_cc.resources" diff --git a/pyblackbird_cc/resources/migrations/__init__.py b/pyblackbird_cc/resources/migrations/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/pyblackbird_cc/resources/migrations/__init__.py diff --git a/pyblackbird_cc/resources/models.py b/pyblackbird_cc/resources/models.py new file mode 100644 index 0000000..6b20219 --- /dev/null +++ b/pyblackbird_cc/resources/models.py @@ -0,0 +1 @@ +# Create your models here. diff --git a/pyblackbird_cc/resources/tests.py b/pyblackbird_cc/resources/tests.py new file mode 100644 index 0000000..a39b155 --- /dev/null +++ b/pyblackbird_cc/resources/tests.py @@ -0,0 +1 @@ +# Create your tests here. diff --git a/pyblackbird_cc/resources/views.py b/pyblackbird_cc/resources/views.py new file mode 100644 index 0000000..60f00ef --- /dev/null +++ b/pyblackbird_cc/resources/views.py @@ -0,0 +1 @@ +# Create your views here. diff --git a/pyblackbird_cc/static/css/project.css b/pyblackbird_cc/static/css/project.css new file mode 100644 index 0000000..f1d543d --- /dev/null +++ b/pyblackbird_cc/static/css/project.css @@ -0,0 +1,13 @@ +/* These styles are generated from project.scss. */ + +.alert-debug { + color: black; + background-color: white; + border-color: #d6e9c6; +} + +.alert-error { + color: #b94a48; + background-color: #f2dede; + border-color: #eed3d7; +} diff --git a/pyblackbird_cc/static/css/wrapper.css b/pyblackbird_cc/static/css/wrapper.css new file mode 100644 index 0000000..c98d927 --- /dev/null +++ b/pyblackbird_cc/static/css/wrapper.css @@ -0,0 +1,492 @@ +*::before, +*::after { + box-sizing: border-box; +} + +* { + font-family: 'Franklin'; + margin: 0; + padding: 0; +} + +@font-face { + font-family: 'Franklin'; + src: url(../fonts/LibreFranklin-VariableFont_wght.ttf); +} + +@font-face { + font-family: 'Franklin-Italic'; + src: url(../fonts/LibreFranklin-Italic.ttf); +} + +body { + background: rgb(224, 221, 217, 0.27); +} + +h1 { + color: #657c76; + font-size: 2.0rem; +} + +h2 { + color: #4e8273; + /* color: white; */ + font-size: 1.8rem; + padding-bottom: 10px; + margin-right: 20px; + margin-top: 20px; + border-radius: 3px 3px 0 0; + padding-left: 2px; + background-size: 100% 50px; + background-repeat: no-repeat; + border-bottom: 10px solid #E4E5E7; + overflow: auto; +} + +.container { + max-width: 87%; + margin: 0 auto; + display: grid; + gap: 0.2rem; +} + +.cell { + background: white; + margin-bottom: 20px; + margin-left: 20px; + margin-right: 20px; + padding-bottom: 10px; + padding-left: 50px; + padding-top: 20px; + border: 0px solid lightgray; + border-radius: 5px; +} + +.cell#create-form-intro p { + background: #bcd8d0; + color: black; + font-size: 0.9rem; + margin: 10px 20px 10px 0px; + max-width: max-content; +} + +.cell p { + color: rgb(37, 95, 140, 0.8); + font-size: 1.2rem; + padding-left: 15px; +} + +q { + display: inline; +} + +q:before { + content: open-quote; +} + +q:after { + content: close-quote; +} + +img { + border-radius: 5px; + margin-right: 20px; + box-shadow: 3px 2px 2px lightgray; + max-width: 400px; + height: auto; + width: 100%; +} + +.inner-grid_1 { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 20px; + margin: 22px 10px 10px 0px; +} + +.inner-grid_1 p { + font-size: 1.6rem; + color: darkgray; + padding-right: 100px; + padding-top: 20px; + padding-bottom: 100px; + text-align: right; +} + +.inner-grid_1 p#sewing-blurb { + font-size: 1.5rem; + color: darkgray; + padding-right: 100px; + padding-top: 20px; + padding-bottom: 100px; + text-align: right; +} + +.inner-grid_2 { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 20px; + margin: 10px 10px 10px 0px; + padding-right: 18px; +} + +.inner_grid_2-img { + box-shadow: 2px 2px 2px lightgray; + border-radius: 2px; +} + +.divider { + color: #e0d9de; + border-width: 1; + margin: 10px 20px 20px 0px; +} + + +a { + font-size: inherit; + color: #394f49; + text-decoration: none; +} + +/* a nice style for a large h2 link*/ +a.no-underline:hover { + font-size: 1.8rem; + color: #4e8273; + text-decoration: none; +} + +header { + padding-left: 10px; + max-width: fit-content; + margin-left: auto; + margin-right: auto; + margin-top: 50px; +} + +.inner-header { + display: grid; + grid-template-columns: 1fr 2fr 1fr; + gap: 10px; + margin-bottom: 20px; +} + +.info { + margin-left: auto; + margin-right: auto; + color: darkgray; + text-align: center; +} + +.info p { + font-size: 1.1rem; + padding: 10px; +} + + +.jumplist { + font-size: 1.1rem; + display: grid; + grid-template-columns: repeat(3, 1fr); + margin-top: 20px; +} + +.jumplist a { + font-weight: bold; + color: #657c76; +} + +a:hover { + color: #2c3d38; + text-decoration: underline; +} + + +.reviews_quote { + font-size: 1.3rem; + font-family: "Franklin-Italic"; + line-height: 1.2em; + color: #4e8273; + margin-left: 2rem; + padding-right: 30px; + padding-top: 20px; + padding-bottom: 100px +} + +.hs_image { + margin-left: auto; + margin-right: 20px; + margin-top: 20px; + max-width: 95%; +} + +/* this means a p subling to reviews_quote */ +.reviews_quote ~ p { + color: darkgray; + padding: 10px 0px; + margin-bottom: 20px; +} + +.reviews_quote_container { + padding: 0px 100px 0px 100px; + border-bottom: 5px solid #f4f4f4; + margin: 30px 25px 20px 25px; +} + +.footer { + margin-left: auto; + margin-right: auto; + margin-bottom: 10px; + color: darkgray; + font-size: 0.8rem; + text-align: center; +} + +.footer > p { + padding: 5px; +} + +/* Media query for narrow screens */ +@media only screen and (max-width: 600px) { + .inner-grid_1 { + grid-template-columns: 1fr; /* Switch to single column layout */ + } + + .inner-grid_1 p { + text-align: left; + font-size: 1.5rem; + padding-top: 1px; + margin-bottom: 5px; + } + + .inner-grid_1 p#sewing-blurb { + font-size: 1.5rem; + color: darkgray; + padding-right: 10px; + } + + .inner-grid_2 { + grid-template-columns: 1fr; /* Switch to single column layout */ + } + + .container { + max-width: 97%; + } + + .cell { + padding-left: 20px; + } + + img { + padding: 0; + } + + .reviews_quote_container { + padding-left: 10px; + padding-right: 10px; + } +} + +@media only screen and (max-width: 1200px) { + .inner-grid_1 p { + text-align: left; + font-size: 1.7rem; + padding-top: 10px; + padding-right: 20px; + } + + .inner-grid_1 p#sewing-blurb { + font-size: 1.4rem; + padding-top: 5px; + padding-right: 20px; + } +} + +@media only screen and (max-width: 900px) { + .inner-grid_1 p { + text-align: left; + font-size: 1.2rem; + padding-top: 10px; + padding-right: 20px; + } + + .inner-grid_1 p#sewing-blurb { + font-size: 1.2rem; + padding-top: 5px; + } + + .reviews_quote_container { + padding-left: 10px; + padding-right: 10px; + } +} + +:root { + box-sizing: border-box; + --co-body-bg: #eee; + --co-body-text: #444; + --co-body-accent: #4834d4; + --co-body-accent-contrast: #fff; + --co-textfld-bg: #fff; + --co-textfld-border: #ccc; + --co-textfld-active-border: #aaa; + --co-textfld-focus-border: var(--co-body-accent); + --co-textfld-valid-border: hsl(140 50% 75%); + --co-textfld-valid-active-border: hsl(140 50% 65%); + --co-textfld-valid-focus-border: hsl(140 50% 50%); + --co-textfld-invalid-border: hsl(20 65% 75%); + --co-textfld-invalid-active-border: hsl(20 65% 65%); + --co-textfld-invalid-focus-border: hsl(20 65% 50%); + --co-btn-text: var(--co-body-accent-contrast); + --co-btn-bg: var(--co-body-accent); + --co-btn-active-bg: #333; + --co-btn-focus-bg: #333; +} + +.dark-mode { + --co-body-bg: #111; + --co-body-text: #ddd; + --co-body-accent: #6c5ce7; + --co-body-accent-contrast: #fff; + + --co-textfld-bg: #222; + --co-textfld-border: #333; + --co-textfld-active-border: #444; + --co-textfld-focus-border: var(--co-body-accent); + + --co-textfld-valid-border: hsl(140 90% 20%); + --co-textfld-valid-active-border: hsl(140 90% 30%); + --co-textfld-valid-focus-border: hsl(140 90% 45%); + + --co-textfld-invalid-border: hsl(20 90% 20%); + --co-textfld-invalid-active-border: hsl(20 90% 30%); + --co-textfld-invalid-focus-border: hsl(20 90% 45%); + + --co-btn-text: var(--co-body-accent-contrast); + --co-btn-bg: var(--co-body-accent); + --co-btn-active-bg: #333; + --co-btn-focus-bg: #333; +} + +.dark-mode { + color-scheme: dark; +} + +*, +*::before, +*::after { + box-sizing: inherit; +} + +body { + font: 1em/160% sans-serif; + background-color: var(--co-body-bg); + color: var(--co-body-text); +} + + +.resource-metadata-panel { + max-width: 95%; + border-radius: 6px; + background-color: lightgray; /* Choose a nice green color */ + padding: 20px; + box-sizing: border-box; + color: #393939; + margin-top: 20px; + margin-right: 20px; +} + +.resource-title { + color: #242424; + margin: 10px 0px 20px 0px; + font-size: 1.5rem; +} + +.resource-details { + display: flex; + justify-content: space-between; +} + +.resource-details div { + font-size: 1.1rem; + font-weight: bold; +} + +.resource-img-detail { + margin-top: 24px; + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 10px; +} + +.resource-img-detail img { + max-height: 400px; + width: auto; + object-fit: contain; + flex: 0 0 calc(33.33% - 10px); +} + +@media screen and (max-width: 768px) { + .resource-img-detail img { + flex: 0 0 calc(50% - 10px); + } +} + +@media screen and (max-width: 480px) { + .resource-img-detail img { + flex: 0 0 100%; + } +} + +.resource-download-panel { + max-width: 95%; + margin-bottom: 16px; + margin-top: 16px; + padding: 16px 24px 24px; + background-color: #fff; + border-radius: 4px; + box-shadow: 0 1px 5px 0 rgba(0, 0, 0, .2), 0 2px 1px 0 rgba(0, 0, 0, .12), 0 1px 1px 0 rgba(0, 0, 0, .12); +} + +.resource-download-panel h4 { + color: green; + font-size: 1.2rem; +} + +.resource-description-panel { + max-width: 95%; + margin-bottom: 16px; + margin-top: 16px; + padding: 16px 24px 24px; + background-color: #fff; + border-radius: 4px; + box-shadow: 0 1px 5px 0 rgba(0, 0, 0, .2), 0 2px 1px 0 rgba(0, 0, 0, .12), 0 1px 1px 0 rgba(0, 0, 0, .12); +} + +.resource-description-panel h3 { + margin-top: 12px; +} + +/* to select any h3 tag after a div of class resource-metadata-panel */ +.feature-pdf-page-title { + background: #598268; + margin: 20px 0px 10px 0px; + max-width: 95%; + padding: 10px 12px 10px; + color: white; + border-radius: 4px; +} + +.admin-links { + display: flex; + justify-content: space-between; + margin-right: 40px; +} + +.admin-links a { + padding: 10px 20px; + background-color: #f4b938; + color: black; + border-radius: 4px; + text-decoration: none; +} diff --git a/pyblackbird_cc/static/fonts/.gitkeep b/pyblackbird_cc/static/fonts/.gitkeep new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/pyblackbird_cc/static/fonts/.gitkeep diff --git a/pyblackbird_cc/static/fonts/LibreFranklin-Italic.ttf b/pyblackbird_cc/static/fonts/LibreFranklin-Italic.ttf Binary files differnew file mode 100644 index 0000000..e0b3c80 --- /dev/null +++ b/pyblackbird_cc/static/fonts/LibreFranklin-Italic.ttf diff --git a/pyblackbird_cc/static/fonts/LibreFranklin-VariableFont_wght.ttf b/pyblackbird_cc/static/fonts/LibreFranklin-VariableFont_wght.ttf Binary files differnew file mode 100644 index 0000000..4c4c09c --- /dev/null +++ b/pyblackbird_cc/static/fonts/LibreFranklin-VariableFont_wght.ttf diff --git a/pyblackbird_cc/static/images/favicons/favicon.ico b/pyblackbird_cc/static/images/favicons/favicon.ico Binary files differnew file mode 100644 index 0000000..e1c1dd1 --- /dev/null +++ b/pyblackbird_cc/static/images/favicons/favicon.ico diff --git a/pyblackbird_cc/static/js/project.js b/pyblackbird_cc/static/js/project.js new file mode 100644 index 0000000..d26d23b --- /dev/null +++ b/pyblackbird_cc/static/js/project.js @@ -0,0 +1 @@ +/* Project specific Javascript goes here. */ diff --git a/pyblackbird_cc/templates/403.html b/pyblackbird_cc/templates/403.html new file mode 100644 index 0000000..40954bb --- /dev/null +++ b/pyblackbird_cc/templates/403.html @@ -0,0 +1,15 @@ +{% extends "base.html" %} + +{% block title %} + Forbidden (403) +{% endblock title %} +{% block content %} + <h1>Forbidden (403)</h1> + <p> + {% if exception %} + {{ exception }} + {% else %} + You're not allowed to access this page. + {% endif %} + </p> +{% endblock content %} diff --git a/pyblackbird_cc/templates/403_csrf.html b/pyblackbird_cc/templates/403_csrf.html new file mode 100644 index 0000000..40954bb --- /dev/null +++ b/pyblackbird_cc/templates/403_csrf.html @@ -0,0 +1,15 @@ +{% extends "base.html" %} + +{% block title %} + Forbidden (403) +{% endblock title %} +{% block content %} + <h1>Forbidden (403)</h1> + <p> + {% if exception %} + {{ exception }} + {% else %} + You're not allowed to access this page. + {% endif %} + </p> +{% endblock content %} diff --git a/pyblackbird_cc/templates/404.html b/pyblackbird_cc/templates/404.html new file mode 100644 index 0000000..2399b79 --- /dev/null +++ b/pyblackbird_cc/templates/404.html @@ -0,0 +1,15 @@ +{% extends "base.html" %} + +{% block title %} + Page not found +{% endblock title %} +{% block content %} + <h1>Page not found</h1> + <p> + {% if exception %} + {{ exception }} + {% else %} + This is not the page you were looking for. + {% endif %} + </p> +{% endblock content %} diff --git a/pyblackbird_cc/templates/500.html b/pyblackbird_cc/templates/500.html new file mode 100644 index 0000000..c4e2fa3 --- /dev/null +++ b/pyblackbird_cc/templates/500.html @@ -0,0 +1,12 @@ +{% extends "base.html" %} + +{% block title %} + Server Error +{% endblock title %} +{% block content %} + <h1>Ooops!!! 500</h1> + <h3>Looks like something went wrong!</h3> + <p> + We track these errors automatically, but if the problem persists feel free to contact us. In the meantime, try refreshing. + </p> +{% endblock content %} diff --git a/pyblackbird_cc/templates/account/base_manage_password.html b/pyblackbird_cc/templates/account/base_manage_password.html new file mode 100644 index 0000000..20c44f7 --- /dev/null +++ b/pyblackbird_cc/templates/account/base_manage_password.html @@ -0,0 +1,10 @@ +{% extends "account/base_manage.html" %} + +{% block main %} + <div class="card"> + <div class="card-body"> + {% block content %} + {% endblock content %} + </div> + </div> +{% endblock main %} diff --git a/pyblackbird_cc/templates/allauth/elements/alert.html b/pyblackbird_cc/templates/allauth/elements/alert.html new file mode 100644 index 0000000..535d394 --- /dev/null +++ b/pyblackbird_cc/templates/allauth/elements/alert.html @@ -0,0 +1,7 @@ +{% load i18n %} +{% load allauth %} + +<div class="alert alert-error"> + {% slot message %} +{% endslot %} +</div> diff --git a/pyblackbird_cc/templates/allauth/elements/badge.html b/pyblackbird_cc/templates/allauth/elements/badge.html new file mode 100644 index 0000000..e86669b --- /dev/null +++ b/pyblackbird_cc/templates/allauth/elements/badge.html @@ -0,0 +1,6 @@ +{% load allauth %} + +<span class="badge {% if 'success' in attrs.tags %}bg-success{% endif %} {% if 'warning' in attrs.tags %}bg-warning{% endif %} {% if 'secondary' in attrs.tags %}bg-secondary{% endif %} {% if 'danger' in attrs.tags %}bg-danger{% endif %} {% if 'primary' in attrs.tags %}bg-primary{% endif %}"> + {% slot %} +{% endslot %} +</span> diff --git a/pyblackbird_cc/templates/allauth/elements/button.html b/pyblackbird_cc/templates/allauth/elements/button.html new file mode 100644 index 0000000..b88a209 --- /dev/null +++ b/pyblackbird_cc/templates/allauth/elements/button.html @@ -0,0 +1,20 @@ +{% load allauth %} + +{% comment %} djlint:off {% endcomment %} +<{% if attrs.href %}a href="{{ attrs.href }}"{% else %}button{% endif %} + {% if attrs.form %}form="{{ attrs.form }}"{% endif %} + {% if attrs.id %}id="{{ attrs.id }}"{% endif %} + {% if attrs.name %}name="{{ attrs.name }}"{% endif %} + {% if attrs.type %}type="{{ attrs.type }}"{% endif %} + class="btn +{% if 'success' in attrs.tags %}btn-success +{% elif 'warning' in attrs.tags %}btn-warning +{% elif 'secondary' in attrs.tags %}btn-secondary +{% elif 'danger' in attrs.tags %}btn-danger +{% elif 'primary' in attrs.tags %}btn-primary +{% else %}btn-primary +{% endif %}" +> + {% slot %} + {% endslot %} + </{% if attrs.href %}a{% else %}button{% endif %}> diff --git a/pyblackbird_cc/templates/allauth/elements/field.html b/pyblackbird_cc/templates/allauth/elements/field.html new file mode 100644 index 0000000..dc5f303 --- /dev/null +++ b/pyblackbird_cc/templates/allauth/elements/field.html @@ -0,0 +1,66 @@ +{% load allauth %} +{% load crispy_forms_tags %} + +{% if attrs.type == "textarea" %} + <div class="row mb-3"> + <div class="col-sm-10"> + <label for="{{ attrs.id }}"> + {% slot label %} + {% endslot %} + </label> + </div> + <textarea {% if attrs.required %}required{% endif %} + {% if attrs.rows %}rows="{{ attrs.rows }}"{% endif %} + {% if attrs.disabled %}disabled{% endif %} + {% if attrs.readonly %}readonly{% endif %} + {% if attrs.checked %}checked{% endif %} + {% if attrs.name %}name="{{ attrs.name }}"{% endif %} + {% if attrs.id %}id="{{ attrs.id }}"{% endif %} + {% if attrs.placeholder %}placeholder="{{ attrs.placeholder }}"{% endif %} + class="form-control">{% slot value %}{% endslot %}</textarea> +</div> +{% elif attrs.type == "radio" %} +<div class="row mb-3"> + <div class="col-sm-10"> + <div class="form-check"> + <input {% if attrs.required %}required{% endif %} + {% if attrs.disabled %}disabled{% endif %} + {% if attrs.readonly %}readonly{% endif %} + {% if attrs.checked %}checked{% endif %} + {% if attrs.name %}name="{{ attrs.name }}"{% endif %} + {% if attrs.id %}id="{{ attrs.id }}"{% endif %} + {% if attrs.placeholder %}placeholder="{{ attrs.placeholder }}"{% endif %} + {% if attrs.autocomplete %}autocomplete="{{ attrs.autocomplete }}"{% endif %} + value="{{ attrs.value|default_if_none:"" }}" + type="{{ attrs.type }}" /> + <label class="form-check-label" for="{{ attrs.id }}"> + {% slot label %} + {% endslot %} + </label> + </div> +</div> +</div> +{% else %} +<div class="col-sm-10"> + <label for="{{ attrs.id }}"> + {% slot label %} + {% endslot %} +</label> +</div> +<div class="col-sm-10"> + <input {% if attrs.required %}required{% endif %} + {% if attrs.disabled %}disabled{% endif %} + {% if attrs.readonly %}readonly{% endif %} + {% if attrs.checked %}checked{% endif %} + {% if attrs.name %}name="{{ attrs.name }}"{% endif %} + {% if attrs.id %}id="{{ attrs.id }}"{% endif %} + {% if attrs.placeholder %}placeholder="{{ attrs.placeholder }}"{% endif %} + {% if attrs.autocomplete %}autocomplete="{{ attrs.autocomplete }}"{% endif %} + value="{{ attrs.value|default_if_none:"" }}" + type="{{ attrs.type }}" + class="form-control" /> +</div> +{% endif %} +{% if slots.help_text %} + <div class="form-text">{% slot help_text %}{% endslot %}</div> +{% endif %} diff --git a/pyblackbird_cc/templates/allauth/elements/fields.html b/pyblackbird_cc/templates/allauth/elements/fields.html new file mode 100644 index 0000000..ae8e104 --- /dev/null +++ b/pyblackbird_cc/templates/allauth/elements/fields.html @@ -0,0 +1,3 @@ +{% load crispy_forms_tags %} + +{{ attrs.form|crispy }} diff --git a/pyblackbird_cc/templates/allauth/elements/panel.html b/pyblackbird_cc/templates/allauth/elements/panel.html new file mode 100644 index 0000000..43a7a54 --- /dev/null +++ b/pyblackbird_cc/templates/allauth/elements/panel.html @@ -0,0 +1,19 @@ +{% load allauth %} + +<section> + <div class="card mb-4"> + <div class="card-body"> + <h2 class="card-title"> + {% slot title %} + {% endslot %} + </h2> + {% slot body %} + {% endslot %} + {% if slots.actions %} + <ul> + {% for action in slots.actions %}<li>{{ action }}</li>{% endfor %} + </ul> + {% endif %} +</div> +</div> +</section> diff --git a/pyblackbird_cc/templates/allauth/elements/table.html b/pyblackbird_cc/templates/allauth/elements/table.html new file mode 100644 index 0000000..13cc5da --- /dev/null +++ b/pyblackbird_cc/templates/allauth/elements/table.html @@ -0,0 +1,6 @@ +{% load allauth %} + +<table class="table"> + {% slot %} +{% endslot %} +</table> diff --git a/pyblackbird_cc/templates/allauth/layouts/entrance.html b/pyblackbird_cc/templates/allauth/layouts/entrance.html new file mode 100644 index 0000000..4d585cf --- /dev/null +++ b/pyblackbird_cc/templates/allauth/layouts/entrance.html @@ -0,0 +1,34 @@ +{% extends "base.html" %} + +{% load i18n %} + +{% block bodyclass %} + bg-light +{% endblock bodyclass %} +{% block css %} + {{ block.super }} +{% endblock css %} +{% block title %} + {% block head_title %} + {% trans "Sign In" %} + {% endblock head_title %} +{% endblock title %} +{% block body %} + <div class="d-flex justify-content-center h-100 py-4"> + <div class="col-md-4 py-4 my-4 px-4"> + {% if messages %} + {% for message in messages %} + <div class="alert alert-dismissible {% if message.tags %}alert-{{ message.tags }}{% endif %}"> + {{ message }} + <button type="button" + class="btn-close" + data-bs-dismiss="alert" + aria-label="Close"></button> + </div> + {% endfor %} + {% endif %} + {% block content %} + {% endblock content %} + </div> + </div> +{% endblock body %} diff --git a/pyblackbird_cc/templates/allauth/layouts/manage.html b/pyblackbird_cc/templates/allauth/layouts/manage.html new file mode 100644 index 0000000..75b4959 --- /dev/null +++ b/pyblackbird_cc/templates/allauth/layouts/manage.html @@ -0,0 +1,6 @@ +{% extends "base.html" %} + +{% block main %} + {% block content %} + {% endblock content %} +{% endblock main %} diff --git a/pyblackbird_cc/templates/base.html b/pyblackbird_cc/templates/base.html new file mode 100644 index 0000000..7c6f1b6 --- /dev/null +++ b/pyblackbird_cc/templates/base.html @@ -0,0 +1,127 @@ +{% load static i18n %} + +<!DOCTYPE html> +{% get_current_language as LANGUAGE_CODE %} +<html lang="{{ LANGUAGE_CODE }}"> + <head> + <meta charset="utf-8" /> + <meta http-equiv="x-ua-compatible" content="ie=edge" /> + <title> + {% block title %} + pyblackbird-cc + {% endblock title %} + </title> + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> + <meta name="description" content="Joanna Lemon Resources" /> + <meta name="author" content="Matthew Lemon" /> + <link rel="icon" href="{% static 'images/favicons/favicon.ico' %}" /> + {% block css %} + <!-- Latest compiled and minified Bootstrap CSS --> + <link rel="stylesheet" + href="https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.2.3/css/bootstrap.min.css" + integrity="sha512-SbiR/eusphKoMVVXysTKG/7VseWii+Y3FdHrt0EpKgpToZeemhqHeZeLWLhJutz/2ut2Vw1uQEj2MbRF+TVBUA==" + crossorigin="anonymous" + referrerpolicy="no-referrer" /> + <!-- Your stuff: Third-party CSS libraries go here --> + <!-- This file stores project-specific CSS --> + <link href="{% static 'css/project.css' %}" rel="stylesheet" /> + <link rel="stylesheet" href="{% static "css/wrapper.css" %}" type="text/css" media="screen" charset="utf-8" /> + {% endblock css %} + <!-- Le javascript + ================================================== --> + {# Placed at the top of the document so pages load faster with defer #} + {% block javascript %} + <!-- Bootstrap JS --> + {#<script defer#} + {# src="https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.2.3/js/bootstrap.min.js"#} + {# integrity="sha512-1/RvZTcCDEUjY/CypiMz+iqqtaoQfAITmNSJY17Myp4Ms5mdxPS5UV7iOfdZoxcGhzFbOm6sntTKJppjvuhg4g=="#} + {# crossorigin="anonymous"#} + {# referrerpolicy="no-referrer"></script>#} + <!-- Your stuff: Third-party javascript libraries go here --> + <!-- place project specific Javascript in this file --> + <script defer src="{% static 'js/project.js' %}"></script> + {% endblock javascript %} + </head> + <body class="{% block bodyclass %}{% endblock bodyclass %}"> + {% block body %} + <div class="mb-1"> + <nav class="navbar navbar-expand-md navbar-light bg-light"> + <div class="container-fluid"> + <button class="navbar-toggler navbar-toggler-right" + type="button" + data-bs-toggle="collapse" + data-bs-target="#navbarSupportedContent" + aria-controls="navbarSupportedContent" + aria-expanded="false" + aria-label="Toggle navigation"> + <span class="navbar-toggler-icon"></span> + </button> + <a class="navbar-brand" href="{% url 'home' %}">pyblackbird-cc</a> + <div class="collapse navbar-collapse" id="navbarSupportedContent"> + <ul class="navbar-nav mr-auto"> + <li class="nav-item active"> + <a class="nav-link" href="{% url 'home' %}">Home <span class="visually-hidden">(current)</span></a> + </li> + <li class="nav-item"> + <a class="nav-link" href="{% url 'about' %}">About</a> + </li> + {% if request.user.is_authenticated %} + <li class="nav-item"> + <a class="nav-link" href="{% url 'users:detail' request.user.pk %}">{% translate "My Profile" %}</a> + </li> + <li class="nav-item"> + {# URL provided by django-allauth/account/urls.py #} + <a class="nav-link" href="{% url 'account_logout' %}">{% translate "Sign Out" %}</a> + </li> + {% else %} + {% if ACCOUNT_ALLOW_REGISTRATION %} + <li class="nav-item"> + {# URL provided by django-allauth/account/urls.py #} + <a id="sign-up-link" class="nav-link" href="{% url 'account_signup' %}">{% translate "Sign Up" %}</a> + </li> + {% endif %} + <li class="nav-item"> + {# URL provided by django-allauth/account/urls.py #} + <a id="log-in-link" class="nav-link" href="{% url 'account_login' %}">{% translate "Sign In" %}</a> + </li> + {% endif %} + </ul> + </div> + </div> + </nav> + </div> + <div class="container"> + {% if messages %} + {% for message in messages %} + <div class="alert alert-dismissible {% if message.tags %}alert-{{ message.tags }}{% endif %}"> + {{ message }} + <button type="button" + class="btn-close" + data-bs-dismiss="alert" + aria-label="Close"></button> + </div> + {% endfor %} + {% endif %} + {% block main %} + {% block content %} + <p>Use this document as a way to quick start any new project.</p> + {% endblock content %} + {% endblock main %} + </div> + {% endblock body %} + <!-- /container --> + {% block modal %} + {% endblock modal %} + {% block inline_javascript %} + {% comment %} + Script tags with only code, no src (defer by default). To run + with a "defer" so that you run inline code: + <script> + window.addEventListener('DOMContentLoaded', () => { + /* Run whatever you want */ + }); + </script> + {% endcomment %} + {% endblock inline_javascript %} + </body> +</html> diff --git a/pyblackbird_cc/templates/pages/about.html b/pyblackbird_cc/templates/pages/about.html new file mode 100644 index 0000000..94d9808 --- /dev/null +++ b/pyblackbird_cc/templates/pages/about.html @@ -0,0 +1 @@ +{% extends "base.html" %} diff --git a/pyblackbird_cc/templates/pages/home.html b/pyblackbird_cc/templates/pages/home.html new file mode 100644 index 0000000..94d9808 --- /dev/null +++ b/pyblackbird_cc/templates/pages/home.html @@ -0,0 +1 @@ +{% extends "base.html" %} diff --git a/pyblackbird_cc/templates/users/user_detail.html b/pyblackbird_cc/templates/users/user_detail.html new file mode 100644 index 0000000..7d52259 --- /dev/null +++ b/pyblackbird_cc/templates/users/user_detail.html @@ -0,0 +1,31 @@ +{% extends "base.html" %} + +{% load static %} + +{% block title %} + User: + {{ object.name }} +{% endblock title %} +{% block content %} + <div class="container"> + <div class="row"> + <div class="col-sm-12"> + <h2>{{ object.name }}</h2> + </div> + </div> + {% if object == request.user %} + <!-- Action buttons --> + <div class="row"> + <div class="col-sm-12"> + <a class="btn btn-primary" href="{% url 'users:update' %}" role="button">My Info</a> + <a class="btn btn-primary" + href="{% url 'account_email' %}" + role="button">E-Mail</a> + <a class="btn btn-primary" href="{% url 'mfa_index' %}" role="button">MFA</a> + <!-- Your Stuff: Custom user template urls --> + </div> + </div> + <!-- End Action buttons --> + {% endif %} + </div> +{% endblock content %} diff --git a/pyblackbird_cc/templates/users/user_form.html b/pyblackbird_cc/templates/users/user_form.html new file mode 100644 index 0000000..a53b304 --- /dev/null +++ b/pyblackbird_cc/templates/users/user_form.html @@ -0,0 +1,21 @@ +{% extends "base.html" %} + +{% load crispy_forms_tags %} + +{% block title %} + {{ user.name }} +{% endblock title %} +{% block content %} + <h1>{{ user.name }}</h1> + <form class="form-horizontal" + method="post" + action="{% url 'users:update' %}"> + {% csrf_token %} + {{ form|crispy }} + <div class="control-group"> + <div class="controls"> + <button type="submit" class="btn btn-primary">Update</button> + </div> + </div> + </form> +{% endblock content %} diff --git a/pyblackbird_cc/users/__init__.py b/pyblackbird_cc/users/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/pyblackbird_cc/users/__init__.py diff --git a/pyblackbird_cc/users/adapters.py b/pyblackbird_cc/users/adapters.py new file mode 100644 index 0000000..b13f3ca --- /dev/null +++ b/pyblackbird_cc/users/adapters.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +import typing + +from allauth.account.adapter import DefaultAccountAdapter +from allauth.socialaccount.adapter import DefaultSocialAccountAdapter +from django.conf import settings + +if typing.TYPE_CHECKING: + from allauth.socialaccount.models import SocialLogin + from django.http import HttpRequest + + from pyblackbird_cc.users.models import User + + +class AccountAdapter(DefaultAccountAdapter): + def is_open_for_signup(self, request: HttpRequest) -> bool: + return getattr(settings, "ACCOUNT_ALLOW_REGISTRATION", True) + + +class SocialAccountAdapter(DefaultSocialAccountAdapter): + def is_open_for_signup( + self, + request: HttpRequest, + sociallogin: SocialLogin, + ) -> bool: + return getattr(settings, "ACCOUNT_ALLOW_REGISTRATION", True) + + def populate_user( + self, + request: HttpRequest, + sociallogin: SocialLogin, + data: dict[str, typing.Any], + ) -> User: + """ + Populates user information from social provider info. + + See: https://docs.allauth.org/en/latest/socialaccount/advanced.html#creating-and-populating-user-instances + """ + user = super().populate_user(request, sociallogin, data) + if not user.name: + if name := data.get("name"): + user.name = name + elif first_name := data.get("first_name"): + user.name = first_name + if last_name := data.get("last_name"): + user.name += f" {last_name}" + return user diff --git a/pyblackbird_cc/users/admin.py b/pyblackbird_cc/users/admin.py new file mode 100644 index 0000000..04da035 --- /dev/null +++ b/pyblackbird_cc/users/admin.py @@ -0,0 +1,49 @@ +from django.conf import settings +from django.contrib import admin +from django.contrib.auth import admin as auth_admin +from django.contrib.auth.decorators import login_required +from django.utils.translation import gettext_lazy as _ + +from .forms import UserAdminChangeForm +from .forms import UserAdminCreationForm +from .models import User + +if settings.DJANGO_ADMIN_FORCE_ALLAUTH: + # Force the `admin` sign in process to go through the `django-allauth` workflow: + # https://docs.allauth.org/en/latest/common/admin.html#admin + admin.site.login = login_required(admin.site.login) # type: ignore[method-assign] + + +@admin.register(User) +class UserAdmin(auth_admin.UserAdmin): + form = UserAdminChangeForm + add_form = UserAdminCreationForm + fieldsets = ( + (None, {"fields": ("email", "password")}), + (_("Personal info"), {"fields": ("name",)}), + ( + _("Permissions"), + { + "fields": ( + "is_active", + "is_staff", + "is_superuser", + "groups", + "user_permissions", + ), + }, + ), + (_("Important dates"), {"fields": ("last_login", "date_joined")}), + ) + list_display = ["email", "name", "is_superuser"] + search_fields = ["name"] + ordering = ["id"] + add_fieldsets = ( + ( + None, + { + "classes": ("wide",), + "fields": ("email", "password1", "password2"), + }, + ), + ) diff --git a/pyblackbird_cc/users/apps.py b/pyblackbird_cc/users/apps.py new file mode 100644 index 0000000..beec953 --- /dev/null +++ b/pyblackbird_cc/users/apps.py @@ -0,0 +1,13 @@ +import contextlib + +from django.apps import AppConfig +from django.utils.translation import gettext_lazy as _ + + +class UsersConfig(AppConfig): + name = "pyblackbird_cc.users" + verbose_name = _("Users") + + def ready(self): + with contextlib.suppress(ImportError): + import pyblackbird_cc.users.signals # noqa: F401 diff --git a/pyblackbird_cc/users/context_processors.py b/pyblackbird_cc/users/context_processors.py new file mode 100644 index 0000000..e2633ae --- /dev/null +++ b/pyblackbird_cc/users/context_processors.py @@ -0,0 +1,8 @@ +from django.conf import settings + + +def allauth_settings(request): + """Expose some settings from django-allauth in templates.""" + return { + "ACCOUNT_ALLOW_REGISTRATION": settings.ACCOUNT_ALLOW_REGISTRATION, + } diff --git a/pyblackbird_cc/users/forms.py b/pyblackbird_cc/users/forms.py new file mode 100644 index 0000000..fe8a886 --- /dev/null +++ b/pyblackbird_cc/users/forms.py @@ -0,0 +1,44 @@ +from allauth.account.forms import SignupForm +from allauth.socialaccount.forms import SignupForm as SocialSignupForm +from django.contrib.auth import forms as admin_forms +from django.forms import EmailField +from django.utils.translation import gettext_lazy as _ + +from .models import User + + +class UserAdminChangeForm(admin_forms.UserChangeForm): + class Meta(admin_forms.UserChangeForm.Meta): + model = User + field_classes = {"email": EmailField} + + +class UserAdminCreationForm(admin_forms.UserCreationForm): + """ + Form for User Creation in the Admin Area. + To change user signup, see UserSignupForm and UserSocialSignupForm. + """ + + class Meta(admin_forms.UserCreationForm.Meta): + model = User + fields = ("email",) + field_classes = {"email": EmailField} + error_messages = { + "email": {"unique": _("This email has already been taken.")}, + } + + +class UserSignupForm(SignupForm): + """ + Form that will be rendered on a user sign up section/screen. + Default fields will be added automatically. + Check UserSocialSignupForm for accounts created from social. + """ + + +class UserSocialSignupForm(SocialSignupForm): + """ + Renders the form when user has signed up using social accounts. + Default fields will be added automatically. + See UserSignupForm otherwise. + """ diff --git a/pyblackbird_cc/users/managers.py b/pyblackbird_cc/users/managers.py new file mode 100644 index 0000000..d8beaa4 --- /dev/null +++ b/pyblackbird_cc/users/managers.py @@ -0,0 +1,42 @@ +from typing import TYPE_CHECKING + +from django.contrib.auth.hashers import make_password +from django.contrib.auth.models import UserManager as DjangoUserManager + +if TYPE_CHECKING: + from .models import User # noqa: F401 + + +class UserManager(DjangoUserManager["User"]): + """Custom manager for the User model.""" + + def _create_user(self, email: str, password: str | None, **extra_fields): + """ + Create and save a user with the given email and password. + """ + if not email: + msg = "The given email must be set" + raise ValueError(msg) + email = self.normalize_email(email) + user = self.model(email=email, **extra_fields) + user.password = make_password(password) + user.save(using=self._db) + return user + + def create_user(self, email: str, password: str | None = None, **extra_fields): # type: ignore[override] + extra_fields.setdefault("is_staff", False) + extra_fields.setdefault("is_superuser", False) + return self._create_user(email, password, **extra_fields) + + def create_superuser(self, email: str, password: str | None = None, **extra_fields): # type: ignore[override] + extra_fields.setdefault("is_staff", True) + extra_fields.setdefault("is_superuser", True) + + if extra_fields.get("is_staff") is not True: + msg = "Superuser must have is_staff=True." + raise ValueError(msg) + if extra_fields.get("is_superuser") is not True: + msg = "Superuser must have is_superuser=True." + raise ValueError(msg) + + return self._create_user(email, password, **extra_fields) diff --git a/pyblackbird_cc/users/migrations/0001_initial.py b/pyblackbird_cc/users/migrations/0001_initial.py new file mode 100644 index 0000000..6f2c3fb --- /dev/null +++ b/pyblackbird_cc/users/migrations/0001_initial.py @@ -0,0 +1,112 @@ +import django.contrib.auth.models +import django.contrib.auth.validators +import django.utils.timezone +from django.db import migrations +from django.db import models + +import pyblackbird_cc.users.models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ("auth", "0012_alter_user_first_name_max_length"), + ] + + operations = [ + migrations.CreateModel( + name="User", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("password", models.CharField(max_length=128, verbose_name="password")), + ( + "last_login", + models.DateTimeField( + blank=True, null=True, verbose_name="last login", + ), + ), + ( + "is_superuser", + models.BooleanField( + default=False, + help_text="Designates that this user has all permissions without explicitly assigning them.", + verbose_name="superuser status", + ), + ), + ( + "email", + models.EmailField( + unique=True, max_length=254, verbose_name="email address", + ), + ), + ( + "is_staff", + models.BooleanField( + default=False, + help_text="Designates whether the user can log into this admin site.", + verbose_name="staff status", + ), + ), + ( + "is_active", + models.BooleanField( + default=True, + help_text="Designates whether this user should be treated as active. Unselect this instead of deleting accounts.", + verbose_name="active", + ), + ), + ( + "date_joined", + models.DateTimeField( + default=django.utils.timezone.now, verbose_name="date joined", + ), + ), + ( + "name", + models.CharField( + blank=True, max_length=255, verbose_name="Name of User", + ), + ), + ( + "groups", + models.ManyToManyField( + blank=True, + help_text="The groups this user belongs to. A user will get all permissions granted to each of their groups.", + related_name="user_set", + related_query_name="user", + to="auth.Group", + verbose_name="groups", + ), + ), + ( + "user_permissions", + models.ManyToManyField( + blank=True, + help_text="Specific permissions for this user.", + related_name="user_set", + related_query_name="user", + to="auth.Permission", + verbose_name="user permissions", + ), + ), + ], + options={ + "verbose_name": "user", + "verbose_name_plural": "users", + "abstract": False, + }, + managers=[ + ("objects", pyblackbird_cc.users.models.UserManager()), + ], + ), + ] diff --git a/pyblackbird_cc/users/migrations/__init__.py b/pyblackbird_cc/users/migrations/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/pyblackbird_cc/users/migrations/__init__.py diff --git a/pyblackbird_cc/users/models.py b/pyblackbird_cc/users/models.py new file mode 100644 index 0000000..f536bc7 --- /dev/null +++ b/pyblackbird_cc/users/models.py @@ -0,0 +1,38 @@ +from typing import ClassVar + +from django.contrib.auth.models import AbstractUser +from django.db.models import CharField +from django.db.models import EmailField +from django.urls import reverse +from django.utils.translation import gettext_lazy as _ + +from .managers import UserManager + + +class User(AbstractUser): + """ + Default custom user model for pyblackbird-cc. + If adding fields that need to be filled at user signup, + check forms.SignupForm and forms.SocialSignupForms accordingly. + """ + + # First and last name do not cover name patterns around the globe + name = CharField(_("Name of User"), blank=True, max_length=255) + first_name = None # type: ignore[assignment] + last_name = None # type: ignore[assignment] + email = EmailField(_("email address"), unique=True) + username = None # type: ignore[assignment] + + USERNAME_FIELD = "email" + REQUIRED_FIELDS = [] + + objects: ClassVar[UserManager] = UserManager() + + def get_absolute_url(self) -> str: + """Get URL for user's detail view. + + Returns: + str: URL for user detail. + + """ + return reverse("users:detail", kwargs={"pk": self.id}) diff --git a/pyblackbird_cc/users/tasks.py b/pyblackbird_cc/users/tasks.py new file mode 100644 index 0000000..ca51cd7 --- /dev/null +++ b/pyblackbird_cc/users/tasks.py @@ -0,0 +1,9 @@ +from celery import shared_task + +from .models import User + + +@shared_task() +def get_users_count(): + """A pointless Celery task to demonstrate usage.""" + return User.objects.count() diff --git a/pyblackbird_cc/users/tests/__init__.py b/pyblackbird_cc/users/tests/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/pyblackbird_cc/users/tests/__init__.py diff --git a/pyblackbird_cc/users/tests/factories.py b/pyblackbird_cc/users/tests/factories.py new file mode 100644 index 0000000..5c83741 --- /dev/null +++ b/pyblackbird_cc/users/tests/factories.py @@ -0,0 +1,40 @@ +from collections.abc import Sequence +from typing import Any + +from factory import Faker +from factory import post_generation +from factory.django import DjangoModelFactory + +from pyblackbird_cc.users.models import User + + +class UserFactory(DjangoModelFactory): + email = Faker("email") + name = Faker("name") + + @post_generation + def password(self, create: bool, extracted: Sequence[Any], **kwargs): # noqa: FBT001 + password = ( + extracted + if extracted + else Faker( + "password", + length=42, + special_chars=True, + digits=True, + upper_case=True, + lower_case=True, + ).evaluate(None, None, extra={"locale": None}) + ) + self.set_password(password) + + @classmethod + def _after_postgeneration(cls, instance, create, results=None): + """Save again the instance if creating and at least one hook ran.""" + if create and results and not cls._meta.skip_postgeneration_save: + # Some post-generation hooks ran, and may have modified us. + instance.save() + + class Meta: + model = User + django_get_or_create = ["email"] diff --git a/pyblackbird_cc/users/tests/test_admin.py b/pyblackbird_cc/users/tests/test_admin.py new file mode 100644 index 0000000..5132d21 --- /dev/null +++ b/pyblackbird_cc/users/tests/test_admin.py @@ -0,0 +1,65 @@ +import contextlib +from http import HTTPStatus +from importlib import reload + +import pytest +from django.contrib import admin +from django.contrib.auth.models import AnonymousUser +from django.urls import reverse +from pytest_django.asserts import assertRedirects + +from pyblackbird_cc.users.models import User + + +class TestUserAdmin: + def test_changelist(self, admin_client): + url = reverse("admin:users_user_changelist") + response = admin_client.get(url) + assert response.status_code == HTTPStatus.OK + + def test_search(self, admin_client): + url = reverse("admin:users_user_changelist") + response = admin_client.get(url, data={"q": "test"}) + assert response.status_code == HTTPStatus.OK + + def test_add(self, admin_client): + url = reverse("admin:users_user_add") + response = admin_client.get(url) + assert response.status_code == HTTPStatus.OK + + response = admin_client.post( + url, + data={ + "email": "new-admin@example.com", + "password1": "My_R@ndom-P@ssw0rd", + "password2": "My_R@ndom-P@ssw0rd", + }, + ) + assert response.status_code == HTTPStatus.FOUND + assert User.objects.filter(email="new-admin@example.com").exists() + + def test_view_user(self, admin_client): + user = User.objects.get(email="admin@example.com") + url = reverse("admin:users_user_change", kwargs={"object_id": user.pk}) + response = admin_client.get(url) + assert response.status_code == HTTPStatus.OK + + @pytest.fixture() + def _force_allauth(self, settings): + settings.DJANGO_ADMIN_FORCE_ALLAUTH = True + # Reload the admin module to apply the setting change + import pyblackbird_cc.users.admin as users_admin + + with contextlib.suppress(admin.sites.AlreadyRegistered): + reload(users_admin) + + @pytest.mark.django_db() + @pytest.mark.usefixtures("_force_allauth") + def test_allauth_login(self, rf, settings): + request = rf.get("/fake-url") + request.user = AnonymousUser() + response = admin.site.login(request) + + # The `admin` login view should redirect to the `allauth` login view + target_url = reverse(settings.LOGIN_URL) + "?next=" + request.path + assertRedirects(response, target_url, fetch_redirect_response=False) diff --git a/pyblackbird_cc/users/tests/test_forms.py b/pyblackbird_cc/users/tests/test_forms.py new file mode 100644 index 0000000..0e4e17a --- /dev/null +++ b/pyblackbird_cc/users/tests/test_forms.py @@ -0,0 +1,35 @@ +"""Module for all Form Tests.""" + +from django.utils.translation import gettext_lazy as _ + +from pyblackbird_cc.users.forms import UserAdminCreationForm +from pyblackbird_cc.users.models import User + + +class TestUserAdminCreationForm: + """ + Test class for all tests related to the UserAdminCreationForm + """ + + def test_username_validation_error_msg(self, user: User): + """ + Tests UserAdminCreation Form's unique validator functions correctly by testing: + 1) A new user with an existing username cannot be added. + 2) Only 1 error is raised by the UserCreation Form + 3) The desired error message is raised + """ + + # The user already exists, + # hence cannot be created. + form = UserAdminCreationForm( + { + "email": user.email, + "password1": user.password, + "password2": user.password, + }, + ) + + assert not form.is_valid() + assert len(form.errors) == 1 + assert "email" in form.errors + assert form.errors["email"][0] == _("This email has already been taken.") diff --git a/pyblackbird_cc/users/tests/test_managers.py b/pyblackbird_cc/users/tests/test_managers.py new file mode 100644 index 0000000..66cad8d --- /dev/null +++ b/pyblackbird_cc/users/tests/test_managers.py @@ -0,0 +1,55 @@ +from io import StringIO + +import pytest +from django.core.management import call_command + +from pyblackbird_cc.users.models import User + + +@pytest.mark.django_db() +class TestUserManager: + def test_create_user(self): + user = User.objects.create_user( + email="john@example.com", + password="something-r@nd0m!", # noqa: S106 + ) + assert user.email == "john@example.com" + assert not user.is_staff + assert not user.is_superuser + assert user.check_password("something-r@nd0m!") + assert user.username is None + + def test_create_superuser(self): + user = User.objects.create_superuser( + email="admin@example.com", + password="something-r@nd0m!", # noqa: S106 + ) + assert user.email == "admin@example.com" + assert user.is_staff + assert user.is_superuser + assert user.username is None + + def test_create_superuser_username_ignored(self): + user = User.objects.create_superuser( + email="test@example.com", + password="something-r@nd0m!", # noqa: S106 + ) + assert user.username is None + + +@pytest.mark.django_db() +def test_createsuperuser_command(): + """Ensure createsuperuser command works with our custom manager.""" + out = StringIO() + command_result = call_command( + "createsuperuser", + "--email", + "henry@example.com", + interactive=False, + stdout=out, + ) + + assert command_result is None + assert out.getvalue() == "Superuser created successfully.\n" + user = User.objects.get(email="henry@example.com") + assert not user.has_usable_password() diff --git a/pyblackbird_cc/users/tests/test_models.py b/pyblackbird_cc/users/tests/test_models.py new file mode 100644 index 0000000..33802cd --- /dev/null +++ b/pyblackbird_cc/users/tests/test_models.py @@ -0,0 +1,5 @@ +from pyblackbird_cc.users.models import User + + +def test_user_get_absolute_url(user: User): + assert user.get_absolute_url() == f"/users/{user.pk}/" diff --git a/pyblackbird_cc/users/tests/test_tasks.py b/pyblackbird_cc/users/tests/test_tasks.py new file mode 100644 index 0000000..6282bfb --- /dev/null +++ b/pyblackbird_cc/users/tests/test_tasks.py @@ -0,0 +1,17 @@ +import pytest +from celery.result import EagerResult + +from pyblackbird_cc.users.tasks import get_users_count +from pyblackbird_cc.users.tests.factories import UserFactory + +pytestmark = pytest.mark.django_db + + +def test_user_count(settings): + """A basic test to execute the get_users_count Celery task.""" + batch_size = 3 + UserFactory.create_batch(batch_size) + settings.CELERY_TASK_ALWAYS_EAGER = True + task_result = get_users_count.delay() + assert isinstance(task_result, EagerResult) + assert task_result.result == batch_size diff --git a/pyblackbird_cc/users/tests/test_urls.py b/pyblackbird_cc/users/tests/test_urls.py new file mode 100644 index 0000000..e5bdc25 --- /dev/null +++ b/pyblackbird_cc/users/tests/test_urls.py @@ -0,0 +1,19 @@ +from django.urls import resolve +from django.urls import reverse + +from pyblackbird_cc.users.models import User + + +def test_detail(user: User): + assert reverse("users:detail", kwargs={"pk": user.pk}) == f"/users/{user.pk}/" + assert resolve(f"/users/{user.pk}/").view_name == "users:detail" + + +def test_update(): + assert reverse("users:update") == "/users/~update/" + assert resolve("/users/~update/").view_name == "users:update" + + +def test_redirect(): + assert reverse("users:redirect") == "/users/~redirect/" + assert resolve("/users/~redirect/").view_name == "users:redirect" diff --git a/pyblackbird_cc/users/tests/test_views.py b/pyblackbird_cc/users/tests/test_views.py new file mode 100644 index 0000000..5ca13fc --- /dev/null +++ b/pyblackbird_cc/users/tests/test_views.py @@ -0,0 +1,101 @@ +from http import HTTPStatus + +import pytest +from django.conf import settings +from django.contrib import messages +from django.contrib.auth.models import AnonymousUser +from django.contrib.messages.middleware import MessageMiddleware +from django.contrib.sessions.middleware import SessionMiddleware +from django.http import HttpRequest +from django.http import HttpResponseRedirect +from django.test import RequestFactory +from django.urls import reverse +from django.utils.translation import gettext_lazy as _ + +from pyblackbird_cc.users.forms import UserAdminChangeForm +from pyblackbird_cc.users.models import User +from pyblackbird_cc.users.tests.factories import UserFactory +from pyblackbird_cc.users.views import UserRedirectView +from pyblackbird_cc.users.views import UserUpdateView +from pyblackbird_cc.users.views import user_detail_view + +pytestmark = pytest.mark.django_db + + +class TestUserUpdateView: + """ + TODO: + extracting view initialization code as class-scoped fixture + would be great if only pytest-django supported non-function-scoped + fixture db access -- this is a work-in-progress for now: + https://github.com/pytest-dev/pytest-django/pull/258 + """ + + def dummy_get_response(self, request: HttpRequest): + return None + + def test_get_success_url(self, user: User, rf: RequestFactory): + view = UserUpdateView() + request = rf.get("/fake-url/") + request.user = user + + view.request = request + assert view.get_success_url() == f"/users/{user.pk}/" + + def test_get_object(self, user: User, rf: RequestFactory): + view = UserUpdateView() + request = rf.get("/fake-url/") + request.user = user + + view.request = request + + assert view.get_object() == user + + def test_form_valid(self, user: User, rf: RequestFactory): + view = UserUpdateView() + request = rf.get("/fake-url/") + + # Add the session/message middleware to the request + SessionMiddleware(self.dummy_get_response).process_request(request) + MessageMiddleware(self.dummy_get_response).process_request(request) + request.user = user + + view.request = request + + # Initialize the form + form = UserAdminChangeForm() + form.cleaned_data = {} + form.instance = user + view.form_valid(form) + + messages_sent = [m.message for m in messages.get_messages(request)] + assert messages_sent == [_("Information successfully updated")] + + +class TestUserRedirectView: + def test_get_redirect_url(self, user: User, rf: RequestFactory): + view = UserRedirectView() + request = rf.get("/fake-url") + request.user = user + + view.request = request + assert view.get_redirect_url() == f"/users/{user.pk}/" + + +class TestUserDetailView: + def test_authenticated(self, user: User, rf: RequestFactory): + request = rf.get("/fake-url/") + request.user = UserFactory() + response = user_detail_view(request, pk=user.pk) + + assert response.status_code == HTTPStatus.OK + + def test_not_authenticated(self, user: User, rf: RequestFactory): + request = rf.get("/fake-url/") + request.user = AnonymousUser() + response = user_detail_view(request, pk=user.pk) + login_url = reverse(settings.LOGIN_URL) + + assert isinstance(response, HttpResponseRedirect) + assert response.status_code == HTTPStatus.FOUND + assert response.url == f"{login_url}?next=/fake-url/" diff --git a/pyblackbird_cc/users/urls.py b/pyblackbird_cc/users/urls.py new file mode 100644 index 0000000..56c246c --- /dev/null +++ b/pyblackbird_cc/users/urls.py @@ -0,0 +1,12 @@ +from django.urls import path + +from .views import user_detail_view +from .views import user_redirect_view +from .views import user_update_view + +app_name = "users" +urlpatterns = [ + path("~redirect/", view=user_redirect_view, name="redirect"), + path("~update/", view=user_update_view, name="update"), + path("<int:pk>/", view=user_detail_view, name="detail"), +] diff --git a/pyblackbird_cc/users/views.py b/pyblackbird_cc/users/views.py new file mode 100644 index 0000000..26876a1 --- /dev/null +++ b/pyblackbird_cc/users/views.py @@ -0,0 +1,45 @@ +from django.contrib.auth.mixins import LoginRequiredMixin +from django.contrib.messages.views import SuccessMessageMixin +from django.urls import reverse +from django.utils.translation import gettext_lazy as _ +from django.views.generic import DetailView +from django.views.generic import RedirectView +from django.views.generic import UpdateView + +from pyblackbird_cc.users.models import User + + +class UserDetailView(LoginRequiredMixin, DetailView): + model = User + slug_field = "id" + slug_url_kwarg = "id" + + +user_detail_view = UserDetailView.as_view() + + +class UserUpdateView(LoginRequiredMixin, SuccessMessageMixin, UpdateView): + model = User + fields = ["name"] + success_message = _("Information successfully updated") + + def get_success_url(self): + # for mypy to know that the user is authenticated + assert self.request.user.is_authenticated + return self.request.user.get_absolute_url() + + def get_object(self): + return self.request.user + + +user_update_view = UserUpdateView.as_view() + + +class UserRedirectView(LoginRequiredMixin, RedirectView): + permanent = False + + def get_redirect_url(self): + return reverse("users:detail", kwargs={"pk": self.request.user.pk}) + + +user_redirect_view = UserRedirectView.as_view() |