Merge branch 'feat-themes' into 'develop'

feat: Support themes via CHARIOTTE_THEME settings / env variable

See merge request la-chariotte/la-chariotte!136
This commit is contained in:
selfhoster1312 ACAB 2025-04-28 11:20:10 +00:00
commit b938ca4dfe
127 changed files with 932 additions and 8098 deletions

View file

@ -8,3 +8,7 @@ omit =
la_chariotte/settings.py la_chariotte/settings.py
la_chariotte/asgi.py la_chariotte/asgi.py
la_chariotte/wsgi.py la_chariotte/wsgi.py
# We only test the default theme
# Also, code depending on theme_settings in the codebase is marked
# `# pragma: no cover` to avoid false positives.
la_chariotte/themes/*/settings.py

2
.gitignore vendored
View file

@ -8,3 +8,5 @@ local_settings.py
*venv* *venv*
mails.sqlite mails.sqlite
package-lock.json package-lock.json
**/sass/*.css
/static

View file

@ -94,6 +94,8 @@ DATABASES = {
Everything should now be ready to start the server: Everything should now be ready to start the server:
```shell ```shell
python manage.py compilescss
python manage.py collectstatic
python manage.py migrate --settings=local_settings python manage.py migrate --settings=local_settings
python manage.py runserver --settings=local_settings python manage.py runserver --settings=local_settings
``` ```
@ -112,3 +114,14 @@ pip install sendria
sendria --db mails.sqlite sendria --db mails.sqlite
$NAVIGATOR http://127.0.0.1:1080 $NAVIGATOR http://127.0.0.1:1080
``` ```
## Using a custom theme
Themes are provided in the `la_chariotte/themes` folder. The `default` theme is enabled by default, but the `CHARIOTTE_THEME` setting and environment variable allows you to change that. Another `light` theme is provided in the repository for you to try, but you can make your own.
After changing the setting or environment variable:
- delete the `static` folder at the repository root
- run `python manage.py compilescss`
- run `python manage.py collectstatic`
- restart the server

View file

@ -73,9 +73,6 @@ class GroupedOrder(models.Model):
def is_to_be_delivered(self): def is_to_be_delivered(self):
return self.delivery_date >= timezone.now().date() return self.delivery_date >= timezone.now().date()
def get_absolute_url(self):
return reverse("order:manage_items", kwargs={"code": self.code})
def clean_fields(self, exclude=None): def clean_fields(self, exclude=None):
super().clean_fields(exclude=exclude) super().clean_fields(exclude=exclude)
# Ensure that new grouped orders use a delivery_date in the future. # Ensure that new grouped orders use a delivery_date in the future.
@ -172,9 +169,6 @@ class Item(models.Model):
else: else:
return None return None
def get_absolute_url(self):
return reverse("order:manage_items", kwargs={"code": self.grouped_order.code})
def __str__(self): # pragma: no cover def __str__(self): # pragma: no cover
return f"{self.name} ({self.price} €)" return f"{self.name} ({self.price} €)"

View file

@ -11,6 +11,7 @@ from django.views import generic
from django_weasyprint import WeasyTemplateResponseMixin from django_weasyprint import WeasyTemplateResponseMixin
from icalendar import Calendar, Event, vCalAddress, vText from icalendar import Calendar, Event, vCalAddress, vText
from ...settings import theme_settings
from ..forms import GroupedOrderForm, Item, JoinGroupedOrderForm from ..forms import GroupedOrderForm, Item, JoinGroupedOrderForm
from ..models import GroupedOrder, OrderAuthor from ..models import GroupedOrder, OrderAuthor
from .mixins import UserIsOrgaMixin from .mixins import UserIsOrgaMixin
@ -183,6 +184,14 @@ class GroupedOrderCreateView(LoginRequiredMixin, generic.CreateView):
self.object.create_code_from_pk() self.object.create_code_from_pk()
return super().form_valid(form) return super().form_valid(form)
def get_success_url(self):
# If the theme has specified a custom redirect, use it as success URL
theme_redirect = theme_settings.success_url(self)
if theme_redirect: # pragma: no cover
return theme_redirect
else:
return reverse("order:manage_items", kwargs={"code": self.object.code})
class GroupedOrderUpdateView(UserIsOrgaMixin, generic.UpdateView): class GroupedOrderUpdateView(UserIsOrgaMixin, generic.UpdateView):
model = GroupedOrder model = GroupedOrder
@ -198,6 +207,14 @@ class GroupedOrderUpdateView(UserIsOrgaMixin, generic.UpdateView):
kwargs["user"] = self.request.user kwargs["user"] = self.request.user
return kwargs return kwargs
def get_success_url(self):
# If the theme has specified a custom redirect, use it as success URL
theme_redirect = theme_settings.success_url(self)
if theme_redirect: # pragma: no cover
return theme_redirect
else:
return reverse("order:manage_items", kwargs={"code": self.object.code})
class GroupedOrderDuplicateView(UserIsOrgaMixin, generic.RedirectView): class GroupedOrderDuplicateView(UserIsOrgaMixin, generic.RedirectView):
def get_object(self, queryset=None): def get_object(self, queryset=None):

View file

@ -3,6 +3,7 @@ from django.shortcuts import get_object_or_404
from django.urls import reverse_lazy from django.urls import reverse_lazy
from django.views import generic from django.views import generic
from ...settings import theme_settings
from ..forms import ItemCreateForm from ..forms import ItemCreateForm
from ..models import GroupedOrder, Item from ..models import GroupedOrder, Item
@ -21,12 +22,29 @@ class ItemCreateView(UserPassesTestMixin, generic.CreateView):
grouped_order = get_object_or_404(GroupedOrder, code=self.kwargs.get("code")) grouped_order = get_object_or_404(GroupedOrder, code=self.kwargs.get("code"))
return grouped_order.orga == self.request.user return grouped_order.orga == self.request.user
def get_success_url(self):
# If the theme has specified a custom redirect, use it as success URL
theme_redirect = theme_settings.success_url(self)
if theme_redirect: # pragma: no cover
return theme_redirect
else:
return reverse_lazy(
"order:manage_items", kwargs={"code": self.object.grouped_order.code}
)
class ItemDeleteView(UserPassesTestMixin, generic.DeleteView): class ItemDeleteView(UserPassesTestMixin, generic.DeleteView):
model = Item model = Item
def get_success_url(self): def get_success_url(self):
return reverse_lazy("order:manage_items", args=[self.object.grouped_order.code]) # If the theme has specified a custom redirect, use it as success URL
theme_redirect = theme_settings.success_url(self)
if theme_redirect: # pragma: no cover
return theme_redirect
else:
return reverse_lazy(
"order:manage_items", kwargs={"code": self.object.grouped_order.code}
)
def test_func(self): def test_func(self):
# Restrict access to the manager or a superuser # Restrict access to the manager or a superuser

View file

@ -57,10 +57,14 @@ LOGIN_URL = "accounts:login"
LOGIN_REDIRECT_URL = "order:index" LOGIN_REDIRECT_URL = "order:index"
LOGOUT_REDIRECT_URL = "dashboard" LOGOUT_REDIRECT_URL = "dashboard"
CHARIOTTE_THEME = os.getenv("CHARIOTTE_THEME", "default")
TEMPLATES = [ TEMPLATES = [
{ {
"BACKEND": "django.template.backends.django.DjangoTemplates", "BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [BASE_DIR / "la_chariotte" / "templates"], "DIRS": (
[BASE_DIR / "la_chariotte" / "themes" / CHARIOTTE_THEME / "templates"]
),
"APP_DIRS": True, "APP_DIRS": True,
"OPTIONS": { "OPTIONS": {
"context_processors": [ "context_processors": [
@ -134,7 +138,7 @@ USE_TZ = True
# https://docs.djangoproject.com/en/4.1/howto/static-files/ # https://docs.djangoproject.com/en/4.1/howto/static-files/
STATIC_URL = "static/" STATIC_URL = "static/"
STATICFILES_DIRS = ["la_chariotte/static"] STATICFILES_DIRS = [BASE_DIR / "la_chariotte" / "themes" / CHARIOTTE_THEME / "static"]
STATICFILES_FINDERS = [ STATICFILES_FINDERS = [
"django.contrib.staticfiles.finders.FileSystemFinder", "django.contrib.staticfiles.finders.FileSystemFinder",
"django.contrib.staticfiles.finders.AppDirectoriesFinder", "django.contrib.staticfiles.finders.AppDirectoriesFinder",
@ -191,3 +195,12 @@ if DEBUG:
EMAIL_HOST_PASSWORD = "" EMAIL_HOST_PASSWORD = ""
EMAIL_PORT = 1025 EMAIL_PORT = 1025
EMAIL_USE_TLS = False EMAIL_USE_TLS = False
# Load theme settings as la_chariotte.settings.theme_settings
from importlib import import_module
theme_settings = import_module(
"la_chariotte.themes.%s.settings" % CHARIOTTE_THEME
if CHARIOTTE_THEME
else "default"
)

View file

@ -10,6 +10,7 @@ from django.urls import reverse
from django.utils import timezone from django.utils import timezone
from icalendar import Calendar, vText from icalendar import Calendar, vText
from la_chariotte import settings
from la_chariotte.order import models from la_chariotte.order import models
from la_chariotte.tests.utils import create_grouped_order, order_items_in_grouped_order from la_chariotte.tests.utils import create_grouped_order, order_items_in_grouped_order
@ -889,7 +890,8 @@ class TestGroupedOrderCreateView:
}, },
) )
assert response.status_code == 302 assert response.status_code == 302
assert response.url.endswith("gerer-produits") if getattr(settings, "CHARIOTTE_THEME", "default") == "default":
assert response.url.endswith("gerer-produits")
assert models.GroupedOrder.objects.count() == 1 assert models.GroupedOrder.objects.count() == 1
assert models.GroupedOrder.objects.first().code != "" assert models.GroupedOrder.objects.first().code != ""
@ -965,7 +967,8 @@ class TestGroupedOrderUpdateView:
}, },
) )
assert response.status_code == 302 assert response.status_code == 302
assert response.url.endswith("gerer-produits") if getattr(settings, "CHARIOTTE_THEME", "default") == "default":
assert response.url.endswith("gerer-produits")
assert models.GroupedOrder.objects.count() == 1 assert models.GroupedOrder.objects.count() == 1
assert models.GroupedOrder.objects.first().name == "titre test modifié" assert models.GroupedOrder.objects.first().name == "titre test modifié"
assert models.GroupedOrder.objects.first().place == "quelque part" assert models.GroupedOrder.objects.first().place == "quelque part"
@ -1006,7 +1009,8 @@ class TestGroupedOrderUpdateView:
}, },
) )
assert response.status_code == 302 assert response.status_code == 302
assert response.url.endswith("gerer-produits") if getattr(settings, "CHARIOTTE_THEME", "default") == "default":
assert response.url.endswith("gerer-produits")
assert models.GroupedOrder.objects.count() == 1 assert models.GroupedOrder.objects.count() == 1
assert models.GroupedOrder.objects.first().name == "titre test modifié" assert models.GroupedOrder.objects.first().name == "titre test modifié"
assert models.GroupedOrder.objects.first().place == "quelque part" assert models.GroupedOrder.objects.first().place == "quelque part"
@ -1118,7 +1122,8 @@ class TestGroupedOrderAddItemsView:
}, },
) )
assert response.status_code == 302 assert response.status_code == 302
assert response.url.endswith("gerer-produits") if getattr(settings, "CHARIOTTE_THEME", "default") == "default":
assert response.url.endswith("gerer-produits")
assert models.GroupedOrder.objects.count() == 1 assert models.GroupedOrder.objects.count() == 1
# Create an item # Create an item
@ -1126,7 +1131,11 @@ class TestGroupedOrderAddItemsView:
create_item_url = reverse("order:item_create", args=[grouped_order.code]) create_item_url = reverse("order:item_create", args=[grouped_order.code])
response = client_log.post(create_item_url, {"name": "Pain test", "price": "2"}) response = client_log.post(create_item_url, {"name": "Pain test", "price": "2"})
response.status_code == 302 response.status_code == 302
response.url == reverse("order:manage_items", args=[grouped_order.code]) if getattr(settings, "CHARIOTTE_THEME", "default") == "default":
assert response.url == reverse(
"order:manage_items",
args=[grouped_order.code],
)
assert grouped_order.item_set.count() == 1 assert grouped_order.item_set.count() == 1
# Delete the item # Delete the item
@ -1134,7 +1143,11 @@ class TestGroupedOrderAddItemsView:
delete_item_url = reverse("order:item_delete", args=[grouped_order.id, item.id]) delete_item_url = reverse("order:item_delete", args=[grouped_order.id, item.id])
response = client_log.post(delete_item_url) response = client_log.post(delete_item_url)
assert response.status_code == 302 assert response.status_code == 302
assert response.url == reverse("order:manage_items", args=[grouped_order.code]) if getattr(settings, "CHARIOTTE_THEME", "default") == "default":
assert response.url == reverse(
"order:manage_items",
args=[grouped_order.code],
)
assert grouped_order.item_set.count() == 0 assert grouped_order.item_set.count() == 0
def test_create_or_delete_item__not_orga(self, client_log, other_user): def test_create_or_delete_item__not_orga(self, client_log, other_user):
@ -1324,9 +1337,11 @@ class TestGroupedOrderDuplicateView:
# redirection # redirection
assert response.status_code == 302 assert response.status_code == 302
assert response.url == reverse( if getattr(settings, "CHARIOTTE_THEME", "default") == "default":
"order:update_grouped_order", kwargs={"code": new_grouped_order.code} assert response.url == reverse(
) "order:update_grouped_order",
kwargs={"code": new_grouped_order.code},
)
# The initial grouped order did not change # The initial grouped order did not change
assert grouped_order.item_set.first().ordered_nb == 4 assert grouped_order.item_set.first().ordered_nb == 4

View file

@ -2,6 +2,7 @@ import pytest
from django.contrib import auth from django.contrib import auth
from django.urls import reverse from django.urls import reverse
from la_chariotte import settings
from la_chariotte.order import models from la_chariotte.order import models
from .utils import create_grouped_order from .utils import create_grouped_order
@ -28,12 +29,13 @@ class TestItemCreateView:
create_item_view_url, {"name": "titre item", "price": 2} create_item_view_url, {"name": "titre item", "price": 2}
) )
assert response.status_code == 302 assert response.status_code == 302
assert response.url == reverse( if getattr(settings, "CHARIOTTE_THEME", "default") == "default":
"order:manage_items", assert response.url == reverse(
kwargs={ "order:manage_items",
"code": grouped_order.code, kwargs={
}, "code": grouped_order.code,
) },
)
assert models.Item.objects.first().name == "titre item" assert models.Item.objects.first().name == "titre item"
response = client_log.get(response.url) response = client_log.get(response.url)
assert "titre item" in response.content.decode() assert "titre item" in response.content.decode()

View file

View file

View file

@ -0,0 +1,4 @@
# The theme may want to specify which URL a successful form should redirect to
def success_url(view):
# No more form success URLs to overwrite
return None

View file

Before

Width:  |  Height:  |  Size: 547 KiB

After

Width:  |  Height:  |  Size: 547 KiB

View file

Before

Width:  |  Height:  |  Size: 590 KiB

After

Width:  |  Height:  |  Size: 590 KiB

View file

Before

Width:  |  Height:  |  Size: 229 KiB

After

Width:  |  Height:  |  Size: 229 KiB

View file

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

View file

Before

Width:  |  Height:  |  Size: 65 KiB

After

Width:  |  Height:  |  Size: 65 KiB

View file

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 42 KiB

View file

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 57 KiB

View file

Before

Width:  |  Height:  |  Size: 80 KiB

After

Width:  |  Height:  |  Size: 80 KiB

View file

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 45 KiB

View file

Before

Width:  |  Height:  |  Size: 74 KiB

After

Width:  |  Height:  |  Size: 74 KiB

View file

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 38 KiB

View file

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 48 KiB

View file

Before

Width:  |  Height:  |  Size: 298 KiB

After

Width:  |  Height:  |  Size: 298 KiB

View file

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 42 KiB

View file

Before

Width:  |  Height:  |  Size: 242 KiB

After

Width:  |  Height:  |  Size: 242 KiB

View file

Before

Width:  |  Height:  |  Size: 208 KiB

After

Width:  |  Height:  |  Size: 208 KiB

View file

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 57 KiB

View file

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 16 KiB

View file

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 33 KiB

View file

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 24 KiB

View file

Before

Width:  |  Height:  |  Size: 51 KiB

After

Width:  |  Height:  |  Size: 51 KiB

View file

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 42 KiB

View file

Before

Width:  |  Height:  |  Size: 71 KiB

After

Width:  |  Height:  |  Size: 71 KiB

View file

Before

Width:  |  Height:  |  Size: 91 KiB

After

Width:  |  Height:  |  Size: 91 KiB

View file

Before

Width:  |  Height:  |  Size: 87 KiB

After

Width:  |  Height:  |  Size: 87 KiB

View file

Before

Width:  |  Height:  |  Size: 86 KiB

After

Width:  |  Height:  |  Size: 86 KiB

View file

@ -1,12 +1,12 @@
// 1. Import the initial variables // 1. Import the initial variables
@import "../../../node_modules/bulma/sass/utilities/initial-variables" @import "../../../../../node_modules/bulma/sass/utilities/initial-variables"
// 2. Set your own initial variables // 2. Set your own initial variables
@import "./base/variables" @import "./base/variables"
// @import "./base/fonts" // @import "./base/fonts"
// 3. Import the rest of Bulma // 3. Import the rest of Bulma
@import "../../../node_modules/bulma/bulma" @import "../../../../../node_modules/bulma/bulma"
// 4. Import your stuff here // 4. Import your stuff here
@import "./base/global" @import "./base/global"

View file

View file

@ -0,0 +1,31 @@
# The theme may want to specify which URL a successful form should redirect to
def success_url(view):
from django.urls import reverse
from la_chariotte.order import views
if type(view) == views.grouped_order.GroupedOrderOverview:
return reverse(
"order:grouped_order_overview", kwargs={"code": view.object.code}
)
elif type(view) == views.grouped_order.GroupedOrderCreateView:
return reverse(
"order:grouped_order_overview", kwargs={"code": view.object.code}
)
elif type(view) == views.grouped_order.GroupedOrderUpdateView:
return reverse(
"order:grouped_order_overview", kwargs={"code": view.object.code}
)
elif type(view) == views.item.ItemCreateView:
return reverse(
"order:grouped_order_overview",
kwargs={"code": view.object.grouped_order.code},
)
elif type(view) == views.item.ItemDeleteView:
return reverse(
"order:grouped_order_overview",
kwargs={"code": view.object.grouped_order.code},
)
# No more form success URLs to overwrite
return None

View file

@ -0,0 +1 @@
../../default/static/fork-awesome

View file

@ -0,0 +1 @@
../../../default/static/img/contributeureuses.png

View file

@ -0,0 +1 @@
../../../default/static/img/icons

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 19 KiB

View file

@ -0,0 +1 @@
../../../../default/static/img/logos/logo_hashbang.png

View file

@ -0,0 +1 @@
../../../../default/static/img/logos/logo_la_chariotte.png

View file

@ -0,0 +1 @@
../../../default/static/img/notice

View file

@ -0,0 +1 @@
../../../default/static/img/notice_1.jpg

View file

@ -0,0 +1 @@
../../../default/static/img/notice_2.jpg

View file

@ -0,0 +1 @@
../../../default/static/img/notice_3.jpg

View file

@ -0,0 +1 @@
../../../../default/static/sass/base/./_accordion.sass

View file

@ -0,0 +1,14 @@
@media screen and (min-width: $min-desktop)
.content
margin: $base
@media screen and (max-width: $max-tablet)
.content
margin: $small
.formatted-text
white-space: pre-wrap
img.notice-img
border: $info 3px solid
margin-bottom: 1em

View file

@ -0,0 +1 @@
../../../../default/static/sass/base/./_footer.sass

View file

@ -0,0 +1 @@
../../../../default/static/sass/base/./_form.sass

Some files were not shown because too many files have changed in this diff Show more