aboutsummaryrefslogtreecommitdiffstats
path: root/pyblackbird_cc
diff options
context:
space:
mode:
authorMatthew Lemon <y@yulqen.org>2024-05-13 17:26:25 +0100
committerMatthew Lemon <y@yulqen.org>2024-05-13 17:26:25 +0100
commitefbbd480ddc62e695123d31c31d233b0df5155bd (patch)
treebc2fb465edd5050d83c97f280b1aac8e023fe3e5 /pyblackbird_cc
After first pre-commit processing
Diffstat (limited to 'pyblackbird_cc')
-rw-r--r--pyblackbird_cc/__init__.py5
-rw-r--r--pyblackbird_cc/conftest.py14
-rw-r--r--pyblackbird_cc/contrib/__init__.py5
-rw-r--r--pyblackbird_cc/contrib/sites/__init__.py5
-rw-r--r--pyblackbird_cc/contrib/sites/migrations/0001_initial.py43
-rw-r--r--pyblackbird_cc/contrib/sites/migrations/0002_alter_domain_unique.py21
-rw-r--r--pyblackbird_cc/contrib/sites/migrations/0003_set_site_domain_and_name.py63
-rw-r--r--pyblackbird_cc/contrib/sites/migrations/0004_alter_options_ordering_domain.py21
-rw-r--r--pyblackbird_cc/contrib/sites/migrations/__init__.py5
-rw-r--r--pyblackbird_cc/resources/__init__.py0
-rw-r--r--pyblackbird_cc/resources/admin.py1
-rw-r--r--pyblackbird_cc/resources/apps.py6
-rw-r--r--pyblackbird_cc/resources/migrations/__init__.py0
-rw-r--r--pyblackbird_cc/resources/models.py1
-rw-r--r--pyblackbird_cc/resources/tests.py1
-rw-r--r--pyblackbird_cc/resources/views.py1
-rw-r--r--pyblackbird_cc/static/css/project.css13
-rw-r--r--pyblackbird_cc/static/css/wrapper.css492
-rw-r--r--pyblackbird_cc/static/fonts/.gitkeep0
-rw-r--r--pyblackbird_cc/static/fonts/LibreFranklin-Italic.ttfbin0 -> 69376 bytes
-rw-r--r--pyblackbird_cc/static/fonts/LibreFranklin-VariableFont_wght.ttfbin0 -> 103512 bytes
-rw-r--r--pyblackbird_cc/static/images/favicons/favicon.icobin0 -> 8348 bytes
-rw-r--r--pyblackbird_cc/static/js/project.js1
-rw-r--r--pyblackbird_cc/templates/403.html15
-rw-r--r--pyblackbird_cc/templates/403_csrf.html15
-rw-r--r--pyblackbird_cc/templates/404.html15
-rw-r--r--pyblackbird_cc/templates/500.html12
-rw-r--r--pyblackbird_cc/templates/account/base_manage_password.html10
-rw-r--r--pyblackbird_cc/templates/allauth/elements/alert.html7
-rw-r--r--pyblackbird_cc/templates/allauth/elements/badge.html6
-rw-r--r--pyblackbird_cc/templates/allauth/elements/button.html20
-rw-r--r--pyblackbird_cc/templates/allauth/elements/field.html66
-rw-r--r--pyblackbird_cc/templates/allauth/elements/fields.html3
-rw-r--r--pyblackbird_cc/templates/allauth/elements/panel.html19
-rw-r--r--pyblackbird_cc/templates/allauth/elements/table.html6
-rw-r--r--pyblackbird_cc/templates/allauth/layouts/entrance.html34
-rw-r--r--pyblackbird_cc/templates/allauth/layouts/manage.html6
-rw-r--r--pyblackbird_cc/templates/base.html127
-rw-r--r--pyblackbird_cc/templates/pages/about.html1
-rw-r--r--pyblackbird_cc/templates/pages/home.html1
-rw-r--r--pyblackbird_cc/templates/users/user_detail.html31
-rw-r--r--pyblackbird_cc/templates/users/user_form.html21
-rw-r--r--pyblackbird_cc/users/__init__.py0
-rw-r--r--pyblackbird_cc/users/adapters.py48
-rw-r--r--pyblackbird_cc/users/admin.py49
-rw-r--r--pyblackbird_cc/users/apps.py13
-rw-r--r--pyblackbird_cc/users/context_processors.py8
-rw-r--r--pyblackbird_cc/users/forms.py44
-rw-r--r--pyblackbird_cc/users/managers.py42
-rw-r--r--pyblackbird_cc/users/migrations/0001_initial.py112
-rw-r--r--pyblackbird_cc/users/migrations/__init__.py0
-rw-r--r--pyblackbird_cc/users/models.py38
-rw-r--r--pyblackbird_cc/users/tasks.py9
-rw-r--r--pyblackbird_cc/users/tests/__init__.py0
-rw-r--r--pyblackbird_cc/users/tests/factories.py40
-rw-r--r--pyblackbird_cc/users/tests/test_admin.py65
-rw-r--r--pyblackbird_cc/users/tests/test_forms.py35
-rw-r--r--pyblackbird_cc/users/tests/test_managers.py55
-rw-r--r--pyblackbird_cc/users/tests/test_models.py5
-rw-r--r--pyblackbird_cc/users/tests/test_tasks.py17
-rw-r--r--pyblackbird_cc/users/tests/test_urls.py19
-rw-r--r--pyblackbird_cc/users/tests/test_views.py101
-rw-r--r--pyblackbird_cc/users/urls.py12
-rw-r--r--pyblackbird_cc/users/views.py45
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
new file mode 100644
index 0000000..e0b3c80
--- /dev/null
+++ b/pyblackbird_cc/static/fonts/LibreFranklin-Italic.ttf
Binary files differ
diff --git a/pyblackbird_cc/static/fonts/LibreFranklin-VariableFont_wght.ttf b/pyblackbird_cc/static/fonts/LibreFranklin-VariableFont_wght.ttf
new file mode 100644
index 0000000..4c4c09c
--- /dev/null
+++ b/pyblackbird_cc/static/fonts/LibreFranklin-VariableFont_wght.ttf
Binary files differ
diff --git a/pyblackbird_cc/static/images/favicons/favicon.ico b/pyblackbird_cc/static/images/favicons/favicon.ico
new file mode 100644
index 0000000..e1c1dd1
--- /dev/null
+++ b/pyblackbird_cc/static/images/favicons/favicon.ico
Binary files differ
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()