Compare commits

..

41 Commits

Author SHA1 Message Date
Titouan 790a1e15b1 Merge pull request #1383 from ae-utbm/taiste
MAJ cotisation, CI, style et fix
2026-05-11 13:03:22 +02:00
Titouan 387e5fe0a3 Merge pull request #1372 from ae-utbm/update_subscription
delete unused subscriptions
2026-05-10 22:22:04 +02:00
TitouanDor c371a5cf7a modif django.po 2026-05-10 20:27:45 +02:00
TitouanDor 07eae232d0 delete unused sub 2026-05-10 20:27:24 +02:00
klmp200 e4fa9c3c4a Merge pull request #1378 from ae-utbm/fix-subscriptions
Fix subscription form
2026-05-10 14:13:37 +02:00
klmp200 5551fdc953 Apply review comments 2026-05-10 13:39:20 +02:00
klmp200 2d86ba67f4 Fix subscription form 2026-05-10 13:37:40 +02:00
thomas girod deb83ec08f Merge pull request #1380 from ae-utbm/fragment-subscription
refactor: use `FragmentMixin` for subscription fragments
2026-05-10 13:17:41 +02:00
imperosol 94cda48508 fix: shortcut SubscriptionNewUserForm.clean if there are errors 2026-05-10 10:27:12 +02:00
imperosol 7bd2f1da96 refactor: use FragmentMixin for subscription fragments 2026-05-08 13:47:34 +02:00
klmp200 e2d2eb7470 Merge pull request #1377 from ae-utbm/fix-component-nesting
Fix component nesting bug
2026-05-08 13:01:09 +02:00
thomas girod a18f316088 Merge pull request #1362 from ae-utbm/improve-style
Small style improvement for main page
2026-05-08 11:07:52 +02:00
thomas girod fee1bbc5a5 Merge pull request #1366 from ae-utbm/update-ci
update CI
2026-05-08 09:06:48 +02:00
klmp200 cb1a330983 Fix component nesting bug 2026-05-07 13:37:37 +02:00
klmp200 1cfeeefa56 Merge pull request #1376 from ae-utbm/fix-link-once
Fix crashes on *-once elements when called at bad timings
2026-05-06 23:23:02 +02:00
thomas girod 0308d1c887 Merge pull request #1374 from ae-utbm/fix-duplicated-price
fix: duplicated prices on counters
2026-05-06 23:22:35 +02:00
thomas girod 9abd16c8f6 Merge pull request #1375 from ae-utbm/fix-eboutic-js
fix: `this.$refs.basketManagementForm.getElementById is not a function`
2026-05-06 23:22:06 +02:00
klmp200 0405ef424d Fix crashes on *-once elements when called at bad timings 2026-05-06 23:19:35 +02:00
imperosol b79b7cbcf5 fix: this.$refs.basketManagementForm.getElementById is not a function 2026-05-06 23:06:06 +02:00
imperosol 2c259de22c fix: duplicated prices on counters 2026-05-06 23:05:14 +02:00
thomas girod 38f2b6aa7b Merge pull request #1367 from ae-utbm/rolldown-inject
refactor: use rolldown builtin `inject`
2026-05-06 18:21:34 +02:00
thomas girod 37345f1bc4 Merge pull request #1360 from ae-utbm/image_rotation
properly rotate SAS pictures
2026-05-06 18:21:23 +02:00
thomas girod 151d17aca1 Merge pull request #1373 from ae-utbm/localsorage-pictures
fix: `QuotaExceededError` on user pictures load
2026-05-06 13:51:00 +02:00
imperosol d123e5e35b fix: QuotaExceededError on user pictures load 2026-05-06 13:37:55 +02:00
imperosol 00f7afb937 add translations 2026-05-02 17:59:06 +02:00
imperosol 2dbf4cff05 add og tags to eboutic page 2026-05-02 17:30:11 +02:00
imperosol f88c061b02 scss-ify eboutic.css 2026-05-02 17:29:04 +02:00
imperosol 4bd248f827 add transition to user whitelist input 2026-05-02 17:28:47 +02:00
imperosol 2aa6eed2fc improve main page style 2026-05-02 17:28:46 +02:00
imperosol 7fec05820c test: Picture.generate_thumbnails 2026-05-02 17:25:14 +02:00
imperosol 381f1ac829 refactor: use rolldown builtin inject 2026-05-02 17:23:35 +02:00
imperosol 22e6c09c36 remove dead code 2026-05-01 23:20:25 +02:00
imperosol 399a3813f0 feat: rotate pictures with API+AlpineJS 2026-05-01 23:20:15 +02:00
imperosol 441a016025 refactor Picture.generate_thumbnails 2026-05-01 23:15:11 +02:00
imperosol 060dde78e7 add update date to SithFile model 2026-05-01 19:18:38 +02:00
thomas girod b2ffcd3a37 Merge pull request #1365 from ae-utbm/taiste
Product prices, club list page rework and bug fixes
2026-05-01 19:14:03 +02:00
klmp200 f19b3056ef Fix notifications on messages containing quotes 2026-05-01 18:59:58 +02:00
imperosol 5c17337595 update CI 2026-04-30 19:02:19 +02:00
Titouan ca37996d6a Merge pull request #1332 from ae-utbm/taiste
Stats & Whitelist, Eurockéenne, fix pagination, Vite 8, delete unused settings
2026-03-29 16:35:16 +02:00
Titouan 173311c1d5 Merge pull request #1315 from ae-utbm/taiste
Product history, formula management, test election
2026-03-12 11:33:45 +01:00
thomas girod 2995823d6e Merge pull request #1293 from ae-utbm/taiste
Refactors, updates and db optimisations
2026-02-13 15:25:04 +01:00
55 changed files with 826 additions and 698 deletions
+16 -22
View File
@@ -12,7 +12,7 @@ runs:
steps:
- name: Install apt packages
if: ${{ inputs.full == 'true' }}
uses: awalsh128/cache-apt-pkgs-action@v1.4.3
uses: awalsh128/cache-apt-pkgs-action@v1.6.0
with:
packages: gettext
version: 1.0 # increment to reset cache
@@ -23,26 +23,29 @@ runs:
with:
redis-version: "7.x"
- name: Install uv
uses: astral-sh/setup-uv@v5
with:
version: "0.5.14"
enable-cache: true
cache-dependency-glob: "uv.lock"
- name: "Set up Python"
uses: actions/setup-python@v5
uses: actions/setup-python@v6
with:
python-version-file: ".python-version"
- name: Restore cached virtualenv
uses: actions/cache/restore@v4
- name: Install uv
uses: astral-sh/setup-uv@v8.1.0
with:
version: "0.11.8"
enable-cache: false
cache-dependency-glob: "uv.lock"
- name: Restore cached virtualenv
uses: actions/cache@v5
with:
key: venv-${{ runner.os }}-${{ hashFiles('.python-version') }}-${{ hashFiles('pyproject.toml') }}-${{ env.CACHE_SUFFIX }}
path: .venv
key: uv-${{ runner.os }}-${{ hashFiles('uv.lock') }}
restore-keys: |
uv-${{ runner.os }}-${{ hashFiles('uv.lock') }}
uv-${{ runner.os }}
- name: Install dependencies
run: uv sync
run: uv sync --locked
shell: bash
- name: Install Xapian
@@ -50,15 +53,6 @@ runs:
run: uv run ./manage.py install_xapian
shell: bash
# compiling xapian accounts for almost the entirety of the virtualenv setup,
# so we save the virtual environment only on workflows where it has been installed
- name: Save cached virtualenv
if: ${{ inputs.full == 'true' }}
uses: actions/cache/save@v4
with:
key: venv-${{ runner.os }}-${{ hashFiles('.python-version') }}-${{ hashFiles('pyproject.toml') }}-${{ env.CACHE_SUFFIX }}
path: .venv
- name: Compile gettext messages
if: ${{ inputs.full == 'true' }}
run: uv run ./manage.py compilemessages
+4 -4
View File
@@ -18,8 +18,8 @@ jobs:
name: Launch pre-commits checks (ruff)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
- uses: actions/checkout@v6
- uses: actions/setup-python@v6
with:
python-version-file: ".python-version"
- uses: pre-commit/action@v3.0.1
@@ -35,7 +35,7 @@ jobs:
pytest-mark: [not slow]
steps:
- name: Check out repository
uses: actions/checkout@v4
uses: actions/checkout@v6
- uses: ./.github/actions/setup_project
with:
full: true
@@ -49,7 +49,7 @@ jobs:
uv run coverage report
uv run coverage html
- name: Archive code coverage results
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: coverage-report-${{ matrix.pytest-mark }}
path: coverage_report
+1 -3
View File
@@ -14,7 +14,7 @@ jobs:
steps:
- name: SSH Remote Commands
uses: appleboy/ssh-action@v1.1.0
uses: appleboy/ssh-action@v1.2.5
with:
# Proxy
proxy_host : ${{secrets.PROXY_HOST}}
@@ -29,8 +29,6 @@ jobs:
username : ${{secrets.USER}}
key: ${{secrets.KEY}}
script_stop: true
# See https://github.com/ae-utbm/sith/wiki/GitHub-Actions#deployment-action
script: |
cd ${{secrets.SITH_PATH}}
+2 -2
View File
@@ -9,10 +9,10 @@ jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- uses: ./.github/actions/setup_project
- run: echo "cache_id=$(date --utc '+%V')" >> $GITHUB_ENV
- uses: actions/cache@v3
- uses: actions/cache@v5
with:
key: mkdocs-material-${{ env.cache_id }}
path: .cache
+1 -3
View File
@@ -13,7 +13,7 @@ jobs:
steps:
- name: SSH Remote Commands
uses: appleboy/ssh-action@v1.1.0
uses: appleboy/ssh-action@v1.2.5
with:
# Proxy
proxy_host : ${{secrets.PROXY_HOST}}
@@ -28,8 +28,6 @@ jobs:
username : ${{secrets.USER}}
key: ${{secrets.KEY}}
script_stop: true
# See https://github.com/ae-utbm/sith/wiki/GitHub-Actions#deployment-action
script: |
cd ${{secrets.SITH_PATH}}
+1 -2
View File
@@ -26,10 +26,9 @@
{% if club.logo %}
<div class="club_logo"><img src="{{ club.logo.url }}" alt="{{ club.name }}"></div>
{% endif %}
<h3>{{ club.name }}</h3>
{% if page_revision %}
{{ page_revision|markdown }}
{% else %}
<h3>{{ club.name }}</h3>
{% endif %}
</div>
{% endblock %}
+17 -13
View File
@@ -3,6 +3,7 @@
#news {
display: flex;
gap: 1em;
@media (max-width: 800px) {
flex-direction: column;
@@ -26,12 +27,14 @@
}
h3 {
background: $second-color;
box-shadow: $shadow-color 1px 1px 1px;
padding: 0.4em;
--box-shadow: rgb(60 64 67 / 30%) 0 1px 3px 0, rgb(60 64 67 / 15%) 0 3px 7px 2px;
background: lighten($second-color, 5%);
box-shadow: var(--box-shadow);
padding: .75rem;
margin: 0 0 0.5em 0;
text-transform: uppercase;
font-size: 17px;
border-radius: 10px;
&:not(:first-of-type) {
margin: 2em 0 1em 0;
@@ -39,12 +42,11 @@
.feed {
float: right;
color: #f26522;
color: #e25512;
}
}
@media screen and (max-width: $small-devices) {
#left_column,
#right_column {
flex: 100%;
@@ -57,6 +59,7 @@
max-height: 600px;
overflow-y: scroll;
overflow-x: clip;
margin-top: 1em;
#load-more-news-button {
text-align: center;
@@ -76,15 +79,11 @@
font-size: 70%;
margin-bottom: 1em;
h3 {
margin-bottom: 0;
}
#links_content {
overflow: auto;
box-shadow: $shadow-color 1px 1px 1px;
min-height: 20em;
padding-bottom: 1em;
padding: 1em;
h4 {
margin-left: 5px;
@@ -121,6 +120,8 @@
}
#birthdays_content {
box-shadow: $shadow-color 1px 1px 1px;
padding: 1em;
ul.birthdays_year {
margin: 0;
list-style-type: none;
@@ -135,8 +136,7 @@
}
ul {
margin: 0;
margin-left: 1em;
margin: .5em 0 0 1em;
list-style-type: square;
list-style-position: inside;
font-weight: normal;
@@ -150,9 +150,13 @@
/* EVENTS TODAY AND NEXT FEW DAYS */
.news_events_group {
box-shadow: $shadow-color 1px 1px 1px;
margin-left: 1em;
margin-left: 0;
margin-bottom: 0.5em;
@media screen and (max-width: $small-devices) {
margin-left: 3px;
}
.news_events_group_date {
display: table-cell;
padding: 0.6em;
+1 -1
View File
@@ -23,7 +23,7 @@
<a target="#" href="{{ url("com:news_feed") }}"><i class="fa fa-rss feed"></i></a>
</h3>
{% if user.is_authenticated and (user.is_com_admin or user.memberships.board().ongoing().exists()) %}
<a class="btn btn-blue margin-bottom" href="{{ url("com:news_new") }}">
<a class="btn btn-blue" href="{{ url("com:news_new") }}">
<i class="fa fa-plus"></i>
{% trans %}Create news{% endtrans %}
</a>
+2 -1
View File
@@ -7,7 +7,7 @@ from model_bakery import baker
from com.models import News, NewsDate
from core.baker_recipes import subscriber_user
from core.models import Group, Notification, User
from core.models import Group, Notification, SithFile, User
@pytest.mark.django_db
@@ -18,6 +18,7 @@ def test_notification_created():
past_news = baker.make(News, is_published=False)
baker.make(NewsDate, news=past_news, start_date=now() - timedelta(days=1))
com_admin_group = Group.objects.get(pk=settings.SITH_GROUP_COM_ADMIN_ID)
SithFile.objects.filter(owner__in=com_admin_group.users.all()).delete()
com_admin_group.users.all().delete()
Notification.objects.all().delete()
com_admin = baker.make(User, groups=[com_admin_group])
+1 -2
View File
@@ -622,8 +622,7 @@ class Command(BaseCommand):
)
pict.file.name = p.name
pict.full_clean()
pict.generate_thumbnails()
pict.save()
pict.generate_thumbnails(save=True)
img_skia = Picture.objects.get(name="skia.jpg")
img_sli = Picture.objects.get(name="sli.jpg")
@@ -0,0 +1,47 @@
# Generated by Django 5.2.12 on 2026-05-01 08:59
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
from django.db.migrations.state import StateApps
from django.db.models import F
def set_updated_at(apps: StateApps, schema_editor):
SithFile = apps.get_model("core", "SithFile")
SithFile.objects.update(updated_at=F("date"))
class Migration(migrations.Migration):
dependencies = [("core", "0049_user_whitelisted_users")]
operations = [
migrations.AlterField(
model_name="sithfile",
name="moderator",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="moderated_files",
to=settings.AUTH_USER_MODEL,
verbose_name="owner",
),
),
migrations.AlterField(
model_name="sithfile",
name="owner",
field=models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
related_name="owned_files",
to=settings.AUTH_USER_MODEL,
verbose_name="owner",
),
),
migrations.AddField(
model_name="sithfile",
name="updated_at",
field=models.DateTimeField(auto_now=True, verbose_name="updated at"),
),
migrations.RunPython(set_updated_at, reverse_code=migrations.RunPython.noop),
]
+3 -2
View File
@@ -853,7 +853,7 @@ class SithFile(models.Model):
User,
related_name="owned_files",
verbose_name=_("owner"),
on_delete=models.CASCADE,
on_delete=models.PROTECT,
)
edit_groups = models.ManyToManyField(
Group, related_name="editable_files", verbose_name=_("edit group"), blank=True
@@ -865,6 +865,7 @@ class SithFile(models.Model):
mime_type = models.CharField(_("mime type"), max_length=30)
size = models.IntegerField(_("size"), default=0)
date = models.DateTimeField(_("date"), default=timezone.now)
updated_at = models.DateTimeField(_("updated at"), auto_now=True)
is_moderated = models.BooleanField(_("is moderated"), default=False)
moderator = models.ForeignKey(
User,
@@ -872,7 +873,7 @@ class SithFile(models.Model):
verbose_name=_("owner"),
null=True,
blank=True,
on_delete=models.CASCADE,
on_delete=models.SET_NULL,
)
asked_for_removal = models.BooleanField(_("asked for removal"), default=False)
is_in_sas = models.BooleanField(
@@ -28,7 +28,7 @@ export class Tab extends HTMLElement {
return html`
<button
role="tab"
?aria-selected=${this.active}
?aria-selected="${this.active}"
class="tab-header clickable ${this.active ? "active" : ""}"
@click="${() => this.setActive(true)}"
>
@@ -40,7 +40,7 @@ export class Tab extends HTMLElement {
return html`
<section
class="tab-section"
?hidden=${!this.active}
?hidden="${!this.active}"
>
${unsafeHTML(this.getContentHtml())}
</section>
@@ -47,9 +47,18 @@ export function inheritHtmlElement<K extends keyof HTMLElementTagNameMap>(tagNam
implements InheritedHtmlElement<K>
{
readonly inheritedTagName = tagName;
private readonly initializedAttribute = "component-initialized";
node: HTMLElementTagNameMap[K];
connectedCallback(autoAddNode?: boolean) {
// When nesting inherited elements, we might trigger the wrapping twice
// To avoid this, we tag a created element as initialized
// We then skip the initialization step and grab the inner content as the node
if (this.hasAttribute(this.initializedAttribute)) {
this.node = this.firstChild as HTMLElementTagNameMap[K];
return;
}
this.node = document.createElement(this.inheritedTagName);
const attributes: Attr[] = []; // We need to make a copy to delete while iterating
for (const attr of this.attributes) {
@@ -58,6 +67,8 @@ export function inheritHtmlElement<K extends keyof HTMLElementTagNameMap>(tagNam
}
}
this.setAttribute(this.initializedAttribute, "");
// We move compatible attributes to the child element
// This avoids weird inconsistencies between attributes
// when we manipulate the dom in the future
+1 -1
View File
@@ -271,7 +271,7 @@ body {
/*--------------------------------CONTENT------------------------------*/
#content {
padding: 1em 1%;
padding: 1.5em 3%;
box-shadow: $shadow-color 0 5px 10px;
background: $white-color;
overflow: auto;
+3 -3
View File
@@ -1,13 +1,13 @@
<div id="quick-notifications"
x-data="{
x-data='{
messages: [
{%- for message in messages -%}
{%- if not message.extra_tags -%}
{ tag: '{{ message.tags }}', text: '{{ message }}' },
{ tag: {{ message.tags|string|tojson }}, text: {{ message|string|tojson }} },
{%- endif -%}
{%- endfor -%}
]
}"
}'
@quick-notification-add="(e) => messages.push(e?.detail)"
@quick-notification-delete="messages = []">
<template x-for="(message, index) in messages">
+2 -1
View File
@@ -33,7 +33,8 @@
<a href="{{ url("core:file_detail", file_id=f.id) }}">{{ f.name }}</a><br/>
{% trans %}Full name: {% endtrans %}{{ f.get_parent_path()+'/'+f.name }}<br/>
{% trans %}Owner: {% endtrans %}{{ f.owner.get_display_name() }}<br/>
{% trans %}Date: {% endtrans %}{{ f.date|date(DATE_FORMAT) }} {{ f.date|time(TIME_FORMAT) }}<br/>
{% trans %}Date: {% endtrans %}
{{ f.date|date(DATE_FORMAT) }} {{ f.date|time(TIME_FORMAT) }}<br/>
</p>
<p><button
hx-get="{{ url('core:file_moderate', file_id=f.id) }}"
@@ -18,7 +18,7 @@
<span class="helptext">{{ form.is_viewable.help_text }}</span>
{{ form.is_viewable.errors }}
</fieldset>
<fieldset class="form-group" x-show="!isViewable">
<fieldset class="form-group" x-show="!isViewable" x-transition x-cloak>
{{ form.whitelisted_users.as_field_group() }}
</fieldset>
<fieldset class="form-group">
@@ -3,7 +3,7 @@
<script-once type="module" src="{{ js }}"></script-once>
{% endfor %}
{% for css in statics.css %}
<link-once rel="stylesheet" type="text/css" href="{{ css }}" defer></link-once>
<link-once rel="stylesheet" type="text/css" href="{{ css }}"></link-once>
{% endfor %}
<{{ component }} name="{{ widget.name }}" {% include "django/forms/widgets/attrs.html" %}>
+2 -1
View File
@@ -21,7 +21,7 @@ from core.baker_recipes import (
subscriber_user,
very_old_subscriber_user,
)
from core.models import AnonymousUser, Group, User
from core.models import AnonymousUser, Group, SithFile, User
from core.views import UserTabsMixin
from counter.baker_recipes import sale_recipe
from counter.models import Counter, Customer, Permanency, Refilling, Selling
@@ -34,6 +34,7 @@ class TestSearchUsers(TestCase):
def setUpTestData(cls):
# News.author has on_delete=PROTECT, so news must be deleted beforehand
News.objects.all().delete()
SithFile.objects.all().delete()
User.objects.all().delete()
user_recipe = Recipe(
User,
-17
View File
@@ -25,7 +25,6 @@ from django.core.files.base import ContentFile
from django.core.files.uploadedfile import UploadedFile
from django.http import HttpRequest
from django.utils.timezone import localdate
from PIL import ExifTags
from PIL.Image import Image, Resampling
RED_PIXEL_PNG: Final[bytes] = (
@@ -178,22 +177,6 @@ def resize_image_explicit(
return ContentFile(content.getvalue())
def exif_auto_rotate(image):
for orientation in ExifTags.TAGS:
if ExifTags.TAGS[orientation] == "Orientation":
break
exif = dict(image._getexif().items())
if exif[orientation] == 3:
image = image.rotate(180, expand=True)
elif exif[orientation] == 6:
image = image.rotate(270, expand=True)
elif exif[orientation] == 8:
image = image.rotate(90, expand=True)
return image
def get_client_ip(request: HttpRequest) -> str | None:
headers = (
"X_FORWARDED_FOR", # Common header for proxies
+1 -1
View File
@@ -431,7 +431,7 @@ class PriceQuerySet(models.QuerySet):
),
product__archived=False,
product__limit_age__lte=age,
)
).distinct()
class Price(models.Model):
+3 -3
View File
@@ -219,6 +219,6 @@ def test_price_for_user():
recipe.make(amount=1, groups=[groups[1]], is_always_shown=False),
]
qs = Price.objects.order_by("-amount")
assert set(qs.for_user(users[0])) == {prices[0], prices[1], prices[4]}
assert set(qs.for_user(users[1])) == {prices[0], prices[4]}
assert set(qs.for_user(users[2])) == {prices[0], prices[3]}
assert list(qs.for_user(users[0])) == [prices[0], prices[1], prices[4]]
assert list(qs.for_user(users[1])) == [prices[0], prices[4]]
assert list(qs.for_user(users[2])) == [prices[0], prices[3]]
@@ -28,11 +28,8 @@ document.addEventListener("alpine:init", () => {
this.basket = [];
}
}
// It's quite tricky to manually apply attributes to the management part
// of a formset so we dynamically apply it here
this.$refs.basketManagementForm
.getElementById("#id_form-TOTAL_FORMS")
document
.getElementById("id_form-TOTAL_FORMS")
.setAttribute(":value", "basket.length");
},
-163
View File
@@ -1,163 +0,0 @@
#eboutic {
display: flex;
flex-direction: row-reverse;
align-items: flex-start;
column-gap: 20px;
margin: 0 20px 20px;
}
#eboutic-title {
margin-left: 20px;
}
#eboutic h3 {
margin-left: 0;
margin-right: 0;
}
#basket {
min-width: 300px;
border-radius: 8px;
box-shadow:
rgb(60 64 67 / 30%) 0 1px 3px 0,
rgb(60 64 67 / 15%) 0 4px 8px 3px;
padding: 10px;
}
#basket h3 {
margin-top: 0;
}
@media screen and (max-width: 765px) {
#eboutic {
flex-direction: column-reverse;
align-items: center;
margin: 10px;
row-gap: 20px;
}
#eboutic-title {
margin-bottom: 20px;
margin-top: 4px;
}
#basket {
width: -webkit-fill-available;
}
}
#eboutic .item-list {
margin-left: 0;
list-style: none;
}
#eboutic .item-list li {
display: flex;
align-items: center;
margin-bottom: 10px;
}
#eboutic .item-row {
gap: 10px;
}
#eboutic .item-name {
word-break: break-word;
width: 100%;
line-height: 100%;
white-space: normal;
}
#eboutic .fa-plus,
#eboutic .fa-minus {
cursor: pointer;
background-color: #354a5f;
color: white;
border-radius: 50%;
padding: 5px;
font-size: 10px;
line-height: 10px;
width: 10px;
text-align: center;
}
#eboutic .item-quantity {
min-width: 65px;
justify-content: space-between;
align-items: center;
display: flex;
gap: 5px;
}
#eboutic .item-price {
min-width: 65px;
text-align: right;
}
/* CSS du catalogue */
#eboutic #catalog {
display: flex;
flex-grow: 1;
flex-direction: column;
row-gap: 30px;
}
#eboutic .category-header {
margin-bottom: 15px;
}
#eboutic .product-group {
display: flex;
flex-wrap: wrap;
column-gap: 15px;
row-gap: 15px;
}
#eboutic .card.selected::after {
content: "🛒";
position: absolute;
top: 5px;
right: 5px;
padding: 5px;
border-radius: 50%;
box-shadow: 0 0 12px 2px rgb(0 0 0 / 14%);
background-color: white;
width: 20px;
height: 20px;
font-size: 16px;
line-height: 20px;
}
#eboutic .catalog-buttons {
display: flex;
justify-content: center;
column-gap: 30px;
margin: 30px 0 0;
}
#eboutic input {
all: unset;
}
#eboutic .catalog-buttons button {
min-width: 60px;
}
#eboutic .catalog-buttons form {
margin: 0;
}
@media screen and (max-width: 765px) {
#eboutic #catalog {
row-gap: 15px;
width: 100%;
}
#eboutic section {
text-align: center;
}
#eboutic .product-group {
justify-content: space-around;
flex-direction: column;
}
}
+162
View File
@@ -0,0 +1,162 @@
#eboutic-title {
margin-left: 20px;
}
#eboutic {
display: flex;
flex-direction: row-reverse;
align-items: flex-start;
column-gap: 20px;
margin: 0 20px 20px;
h3 {
margin-left: 0;
margin-right: 0;
}
#basket {
--box-shadow:
rgb(60 64 67 / 30%) 0 1px 3px 0,
rgb(60 64 67 / 15%) 0 4px 8px 3px;
min-width: 300px;
border-radius: 8px;
box-shadow: var(--box-shadow);
padding: 10px;
h3 {
margin-top: 0;
}
}
@media screen and (max-width: 765px) {
flex-direction: column-reverse;
align-items: center;
margin: 10px;
row-gap: 20px;
#eboutic-title {
margin-bottom: 20px;
margin-top: 4px;
}
#basket {
width: -webkit-fill-available;
}
}
.item-list {
margin-left: 0;
list-style: none;
li {
display: flex;
align-items: center;
margin-bottom: 10px;
}
}
.item-row {
gap: 10px;
}
.item-name {
word-break: break-word;
width: 100%;
line-height: 100%;
white-space: normal;
}
.fa-plus,
.fa-minus {
cursor: pointer;
background-color: #354a5f;
color: white;
border-radius: 50%;
padding: 5px;
font-size: 10px;
line-height: 10px;
width: 10px;
text-align: center;
}
.item-quantity {
min-width: 65px;
justify-content: space-between;
align-items: center;
display: flex;
gap: 5px;
}
.item-price {
min-width: 65px;
text-align: right;
}
/* CSS du catalogue */
#catalog {
display: flex;
flex-grow: 1;
flex-direction: column;
row-gap: 30px;
}
.category-header {
margin-bottom: 15px;
}
.product-group {
display: flex;
flex-wrap: wrap;
column-gap: 15px;
row-gap: 15px;
}
.card.selected::after {
--box-shadow: 0 0 12px 2px rgb(0 0 0 / 14%);
content: "🛒";
position: absolute;
top: 5px;
right: 5px;
padding: 5px;
border-radius: 50%;
box-shadow: var(--box-shadow);
background-color: white;
width: 20px;
height: 20px;
font-size: 16px;
line-height: 20px;
}
input {
all: unset;
}
.catalog-buttons {
display: flex;
justify-content: center;
column-gap: 30px;
margin: 30px 0 0;
button {
min-width: 60px;
}
form {
margin: 0;
}
}
@media screen and (max-width: 765px) {
#catalog {
row-gap: 15px;
width: 100%;
}
section {
text-align: center;
}
.product-group {
justify-content: space-around;
flex-direction: column;
}
}
}
+9 -1
View File
@@ -8,6 +8,14 @@
{% trans %}The online shop of the association.{% endtrans %}
{%- endblock %}
{% block metatags %}
<meta property="og:url" content="{{ request.build_absolute_uri() }}" />
<meta property="og:type" content="website" />
<meta property="og:title" content="Eboutic" />
<meta property="og:description" content="La boutique en ligne de l'AE" />
<meta property="og:image" content="{{ request.build_absolute_uri(static("core/img/logo_no_text.png")) }}" />
{% endblock %}
{% block additional_js %}
{# This script contains the code to perform requests to manipulate the
user basket without having to reload the page #}
@@ -15,7 +23,7 @@
{% endblock %}
{% block additional_css %}
<link rel="stylesheet" href="{{ static("eboutic/css/eboutic.css") }}">
<link rel="stylesheet" href="{{ static("eboutic/css/eboutic.scss") }}">
<link rel="stylesheet" href="{{ static("core/components/card.scss") }}">
{% endblock %}
+102 -108
View File
@@ -6,7 +6,7 @@
msgid ""
msgstr ""
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-03-23 22:21+0100\n"
"POT-Creation-Date: 2026-05-10 20:27+0200\n"
"PO-Revision-Date: 2016-07-18\n"
"Last-Translator: Maréchal <thomas.girod@utbm.fr\n"
"Language-Team: AE info <ae.info@utbm.fr>\n"
@@ -181,6 +181,22 @@ msgstr "Vous devez être cotisant pour faire partie d'un club"
msgid "You are already a member of this club"
msgstr "Vous êtes déjà membre de ce club."
#: club/forms.py
msgid "Club status"
msgstr "État du club"
#: club/forms.py
msgid "Active"
msgstr "Actif"
#: club/forms.py
msgid "Inactive"
msgstr "Inactif"
#: club/forms.py
msgid "All clubs"
msgstr "Tous les clubs"
#: club/models.py
msgid "slug name"
msgstr "nom slug"
@@ -243,10 +259,6 @@ msgstr "rôle"
msgid "description"
msgstr "description"
#: club/models.py
msgid "past member"
msgstr "ancien membre"
#: club/models.py com/templates/com/mailing_admin.jinja
#: com/templates/com/news_admin_list.jinja com/templates/com/weekmail.jinja
#: core/templates/core/user_clubs.jinja
@@ -301,37 +313,22 @@ msgstr "Cet email est déjà abonné à cette mailing"
msgid "Unregistered user"
msgstr "Utilisateur non enregistré"
#: club/templates/club/club_list.jinja
msgid "Club list"
msgstr "Liste des clubs"
#: club/templates/club/club_list.jinja
msgid "The list of all clubs existing at UTBM."
msgstr "La liste de tous les clubs existants à l'UTBM"
#: club/templates/club/club_list.jinja
msgid "Club list"
msgstr "Liste des clubs"
#: club/templates/club/club_list.jinja
msgid "Filters"
msgstr "Filtres"
#: club/templates/club/club_list.jinja
msgid "Name"
msgstr "Nom"
#: club/templates/club/club_list.jinja
msgid "Club state"
msgstr "Etat du club"
#: club/templates/club/club_list.jinja
msgid "Active"
msgstr "Actif"
#: club/templates/club/club_list.jinja
msgid "Inactive"
msgstr "Inactif"
#: club/templates/club/club_list.jinja
msgid "All clubs"
msgstr "Tous les clubs"
#: club/templates/club/club_list.jinja core/templates/core/base/header.jinja
#: forum/templates/forum/macros.jinja matmat/templates/matmat/search_form.jinja
msgid "Search"
msgstr "Recherche"
#: club/templates/club/club_list.jinja core/templates/core/user_tools.jinja
msgid "New club"
@@ -433,7 +430,7 @@ msgstr "Bénéfice : "
#: counter/templates/counter/cash_summary_list.jinja
#: counter/templates/counter/last_ops.jinja
#: counter/templates/counter/refilling_list.jinja
#: rootplace/templates/rootplace/logs.jinja sas/forms.py
#: rootplace/templates/rootplace/logs.jinja
#: trombi/templates/trombi/user_profile.jinja
msgid "Date"
msgstr "Date"
@@ -581,7 +578,8 @@ msgstr ""
#: counter/templates/counter/invoices_call.jinja
#: counter/templates/counter/product_form.jinja
#: forum/templates/forum/reply.jinja
#: subscription/templates/subscription/fragments/creation_form.jinja
#: subscription/templates/subscription/fragments/creation_form_existing_user.jinja
#: subscription/templates/subscription/fragments/creation_form_new_user.jinja
#: trombi/templates/trombi/comment.jinja
#: trombi/templates/trombi/edit_profile.jinja
#: trombi/templates/trombi/user_tools.jinja
@@ -1692,6 +1690,10 @@ msgstr "taille"
msgid "date"
msgstr "date"
#: core/models.py counter/models.py
msgid "updated at"
msgstr "mis à jour le"
#: core/models.py
msgid "asked for removal"
msgstr "retrait demandé"
@@ -1863,11 +1865,6 @@ msgstr "Connexion"
msgid "Register"
msgstr "Inscription"
#: core/templates/core/base/header.jinja forum/templates/forum/macros.jinja
#: matmat/templates/matmat/search_form.jinja
msgid "Search"
msgstr "Recherche"
#: core/templates/core/base/header.jinja
msgid "Logout"
msgstr "Déconnexion"
@@ -2892,8 +2889,8 @@ msgstr "Nom d'utilisateur, email, ou numéro de compte AE"
#: core/views/forms.py
msgid ""
"Profile: you need to be visible on the picture, in order to be recognized "
"(e.g. by the barmen)"
"Profile: you need to be visible on the picture, in order to be recognized (e."
"g. by the barmen)"
msgstr ""
"Photo de profil: vous devez être visible sur la photo afin d'être reconnu "
"(par exemple par les barmen)"
@@ -3195,10 +3192,6 @@ msgstr "groupe d'achat"
msgid "archived"
msgstr "archivé"
#: counter/models.py
msgid "updated at"
msgstr "mis à jour le"
#: counter/models.py eboutic/models.py
msgid "product"
msgstr "produit"
@@ -3697,10 +3690,10 @@ msgid ""
"orders a sandwich and a soft drink, the formula will be applied and the "
"basket will then contain a sandwich formula instead."
msgstr ""
"Par exemple s'il existe une formule associant un produit « Formule "
"sandwich » aux produits « Sandwich » et « Soft », alors, si une personne "
"commande un sandwich et un soft, la formule sera appliquée et le panier "
"contiendra alors une formule sandwich à la place."
"Par exemple s'il existe une formule associant un produit « Formule sandwich "
"» aux produits « Sandwich » et « Soft », alors, si une personne commande un "
"sandwich et un soft, la formule sera appliquée et le panier contiendra alors "
"une formule sandwich à la place."
#: counter/templates/counter/formula_list.jinja
msgid "New formula"
@@ -3762,8 +3755,8 @@ msgstr ""
#: counter/templates/counter/mails/account_dump.jinja
msgid "If you think this was a mistake, please mail us at ae@utbm.fr."
msgstr ""
"Si vous pensez qu'il s'agit d'une erreur, veuillez envoyer un mail à "
"ae@utbm.fr."
"Si vous pensez qu'il s'agit d'une erreur, veuillez envoyer un mail à ae@utbm."
"fr."
#: counter/templates/counter/mails/account_dump.jinja
msgid ""
@@ -3828,14 +3821,14 @@ msgstr ""
"votre cotisation. Si vous ne renouvelez pas votre cotisation, il n'y aura "
"aucune conséquence autre que le retrait de l'argent de votre compte."
#: counter/templates/counter/product_form.jinja
msgid "Remove price"
msgstr "Retirer le prix"
#: counter/templates/counter/product_form.jinja
msgid "Remove this action"
msgstr "Retirer cette action"
#: counter/templates/counter/product_form.jinja
msgid "Remove price"
msgstr "Retirer le prix"
#: counter/templates/counter/product_form.jinja
#, python-format
msgid "Edit product %(name)s"
@@ -4205,6 +4198,47 @@ msgstr ""
msgid "this page"
msgstr "cette page"
#: eboutic/templates/eboutic/eboutic_main.jinja
msgid "Eurockéennes 2025 partnership"
msgstr "Partenariat Eurockéennes 2025"
#: eboutic/templates/eboutic/eboutic_main.jinja
msgid ""
"Our partner uses Weezevent to sell tickets. Weezevent may collect user info "
"according to its own privacy policy. By clicking the accept button you "
"consent to their terms of services."
msgstr ""
"Notre partenaire utilises Wezevent pour vendre ses billets. Weezevent peut "
"collecter des informations utilisateur conformément à sa propre politique de "
"confidentialité. En cliquant sur le bouton d'acceptation vous consentez à "
"leurs termes de service."
#: eboutic/templates/eboutic/eboutic_main.jinja
msgid "Privacy policy"
msgstr "Politique de confidentialité"
#: eboutic/templates/eboutic/eboutic_main.jinja
#: trombi/templates/trombi/comment_moderation.jinja
msgid "Accept"
msgstr "Accepter"
#: eboutic/templates/eboutic/eboutic_main.jinja
msgid ""
"You must be subscribed to benefit from the partnership with the Eurockéennes."
msgstr ""
"Vous devez être cotisant pour bénéficier du partenariat avec les "
"Eurockéennes."
#: eboutic/templates/eboutic/eboutic_main.jinja
#, python-format
msgid ""
"This partnership offers a discount of up to 33%% on tickets for Friday, "
"Saturday and Sunday, as well as the 3-day package from Friday to Sunday."
msgstr ""
"Ce partenariat permet de profiter d'une réduction jusqu'à 33%% sur les "
"billets du vendredi, du samedi et du dimanche, ainsi qu'au forfait 3 jours, "
"du vendredi au dimanche."
#: eboutic/templates/eboutic/eboutic_main.jinja
msgid "There are no items available for sale"
msgstr "Aucun article n'est disponible à la vente"
@@ -5304,7 +5338,9 @@ msgid "Amicale/DOCEO member"
msgstr "Membre de l'Amicale/DOCEO"
#: sith/settings.py
msgid "UT network member"
#, fuzzy
#| msgid "UT network member"
msgid "UT network member (excluding UTC)"
msgstr "Cotisant du réseau UT"
#: sith/settings.py
@@ -5315,26 +5351,10 @@ msgstr "Membres du CROUS"
msgid "Sbarro/ESTA member"
msgstr "Membre de Sbarro ou de l'ESTA"
#: sith/settings.py
msgid "One semester Welcome Week"
msgstr "Un semestre Welcome Week"
#: sith/settings.py
msgid "Eurok's volunteer"
msgstr "Bénévole Eurockéennes"
#: sith/settings.py
msgid "Six weeks for free"
msgstr "6 semaines gratuites"
#: sith/settings.py
msgid "One day"
msgstr "Un jour"
#: sith/settings.py
msgid "GA staff member"
msgstr "Membre staff GA"
#: sith/settings.py
msgid "One semester (-20%)"
msgstr "Un semestre (-20%)"
@@ -5355,10 +5375,6 @@ msgstr "Cursus branche (-20%)"
msgid "Alternating cursus (-20%)"
msgstr "Cursus alternant (-20%)"
#: sith/settings.py
msgid "One year for free(CA offer)"
msgstr "Une année offerte (Offre CA)"
#: sith/settings.py
msgid "President"
msgstr "Président⸱e"
@@ -5481,7 +5497,7 @@ msgstr "lieu"
msgid "You can not subscribe many time for the same period"
msgstr "Vous ne pouvez pas cotiser plusieurs fois pour la même période"
#: subscription/templates/subscription/forms/create_existing_user.jinja
#: subscription/templates/subscription/fragments/creation_form_existing_user.jinja
msgid ""
"If the subscription is done using the AE account, you must also click it on "
"the AE counter."
@@ -5631,10 +5647,6 @@ msgstr "fin"
msgid "Moderate Trombi comments"
msgstr "Modérer les commentaires du Trombi"
#: trombi/templates/trombi/comment_moderation.jinja
msgid "Accept"
msgstr "Accepter"
#: trombi/templates/trombi/comment_moderation.jinja
msgid "Reject"
msgstr "Refuser"
@@ -5877,38 +5889,20 @@ msgstr "Vous ne pouvez plus écrire de commentaires, la date est passée."
msgid "Maximum characters: %(max_length)s"
msgstr "Nombre de caractères max: %(max_length)s"
#: eboutic/templates/eboutic/eboutic_main.jinja
msgid "Eurockéennes 2025 partnership"
msgstr "Partenariat Eurockéennes 2025"
#~ msgid "past member"
#~ msgstr "ancien membre"
#: eboutic/templates/eboutic/eboutic_main.jinja
msgid ""
"Our partner uses Weezevent to sell tickets. Weezevent may collect user info "
"according to its own privacy policy. By clicking the accept button you "
"consent to their terms of services."
msgstr ""
"Notre partenaire utilises Wezevent pour vendre ses billets. Weezevent peut "
"collecter des informations utilisateur conformément à sa propre politique de "
"confidentialité. En cliquant sur le bouton d'acceptation vous consentez à "
"leurs termes de service."
#~ msgid "One semester Welcome Week"
#~ msgstr "Un semestre Welcome Week"
#: eboutic/templates/eboutic/eboutic_main.jinja
msgid "Privacy policy"
msgstr "Politique de confidentialité"
#~ msgid "Eurok's volunteer"
#~ msgstr "Bénévole Eurockéennes"
#: eboutic/templates/eboutic/eboutic_main.jinja
msgid ""
"You must be subscribed to benefit from the partnership with the Eurockéennes."
msgstr ""
"Vous devez être cotisant pour bénéficier du partenariat avec les "
"Eurockéennes."
#~ msgid "Six weeks for free"
#~ msgstr "6 semaines gratuites"
#: eboutic/templates/eboutic/eboutic_main.jinja
#, python-format
msgid ""
"This partnership offers a discount of up to 33%% on tickets for Friday, "
"Saturday and Sunday, as well as the 3-day package from Friday to Sunday."
msgstr ""
"Ce partenariat permet de profiter d'une réduction jusqu'à 33%% sur les "
"billets du vendredi, du samedi et du dimanche, ainsi qu'au forfait 3 jours, "
"du vendredi au dimanche."
#~ msgid "GA staff member"
#~ msgstr "Membre staff GA"
#~ msgid "One year for free(CA offer)"
#~ msgstr "Une année offerte (Offre CA)"
+2 -1
View File
@@ -7,13 +7,14 @@ from model_bakery import baker
from com.models import News
from core.baker_recipes import subscriber_user
from core.models import User
from core.models import SithFile, User
class TestMatmatronch(TestCase):
@classmethod
def setUpTestData(cls):
News.objects.all().delete()
SithFile.objects.all().delete()
User.objects.all().delete()
users = [
baker.prepare(User, promo=17),
-64
View File
@@ -43,7 +43,6 @@
"@babel/preset-env": "^7.29.2",
"@biomejs/biome": "^2.4.13",
"@hey-api/openapi-ts": "^0.94.5",
"@rollup/plugin-inject": "^5.0.5",
"@types/alpinejs": "^3.13.11",
"@types/cytoscape-cxtmenu": "^3.4.5",
"@types/cytoscape-klay": "^3.1.5",
@@ -2383,52 +2382,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/@rollup/plugin-inject": {
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/@rollup/plugin-inject/-/plugin-inject-5.0.5.tgz",
"integrity": "sha512-2+DEJbNBoPROPkgTDNe8/1YXWcqxbN5DTjASVIOx8HS+pITXushyNiBV56RB08zuptzz8gT3YfkqriTBVycepg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@rollup/pluginutils": "^5.0.1",
"estree-walker": "^2.0.2",
"magic-string": "^0.30.3"
},
"engines": {
"node": ">=14.0.0"
},
"peerDependencies": {
"rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0"
},
"peerDependenciesMeta": {
"rollup": {
"optional": true
}
}
},
"node_modules/@rollup/pluginutils": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz",
"integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "^1.0.0",
"estree-walker": "^2.0.2",
"picomatch": "^4.0.2"
},
"engines": {
"node": ">=14.0.0"
},
"peerDependencies": {
"rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0"
},
"peerDependenciesMeta": {
"rollup": {
"optional": true
}
}
},
"node_modules/@sentry-internal/browser-utils": {
"version": "10.51.0",
"resolved": "https://registry.npmjs.org/@sentry-internal/browser-utils/-/browser-utils-10.51.0.tgz",
@@ -3378,13 +3331,6 @@
"node": ">=6"
}
},
"node_modules/estree-walker": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
"integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
"dev": true,
"license": "MIT"
},
"node_modules/esutils": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
@@ -4075,16 +4021,6 @@
"yallist": "^3.0.2"
}
},
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/marked": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/marked/-/marked-4.3.0.tgz",
-1
View File
@@ -28,7 +28,6 @@
"@babel/preset-env": "^7.29.2",
"@biomejs/biome": "^2.4.13",
"@hey-api/openapi-ts": "^0.94.5",
"@rollup/plugin-inject": "^5.0.5",
"@types/alpinejs": "^3.13.11",
"@types/cytoscape-cxtmenu": "^3.4.5",
"@types/cytoscape-klay": "^3.1.5",
+1 -1
View File
@@ -46,7 +46,7 @@ dependencies = [
"ical>=11.1.0,<14.0.0",
"redis[hiredis]>=6.4.0,<8.0.0",
"environs[django]>=15.0.1,<16.0.0",
"requests>=2.33.1,<3.0.0",
"requests>=2.32.5,<3.0.0",
"honcho>=2.0.0",
"psutil>=7.2.2,<8.0.0",
"celery[redis]>=5.6.2,<7",
+14 -2
View File
@@ -126,9 +126,8 @@ class PicturesController(ControllerBase):
if self_moderate:
new.moderator = user
try:
new.generate_thumbnails()
new.full_clean()
new.save()
new.generate_thumbnails(save=True)
except ValidationError as e:
return self.create_response({"detail": dict(e)}, status_code=409)
@@ -177,6 +176,19 @@ class PicturesController(ControllerBase):
def delete_picture(self, picture_id: int):
self.get_object_or_exception(Picture, pk=picture_id).delete()
@route.post(
"/{picture_id}/rotate/{direction}",
permissions=[IsSasAdmin],
response=PictureSchema,
url_name="rotate_picture",
)
def rotate_picture(self, picture_id: int, direction: Literal["left", "right"]):
"""Rotate the given picture and returns its edited data."""
angle = 90 if direction == "left" else 270
picture = self.get_object_or_exception(Picture, pk=picture_id)
picture.rotate(angle)
return picture
@route.patch(
"/{picture_id}/moderation",
permissions=[IsSasAdmin],
+13 -2
View File
@@ -1,13 +1,24 @@
from django.conf import settings
from model_bakery import seq
from model_bakery.recipe import Recipe
from model_bakery.recipe import Recipe, foreign_key
from sas.models import Picture
from sas.models import Album, Picture
album_recipe = Recipe(
Album,
is_in_sas=True,
is_folder=True,
is_moderated=True,
parent_id=settings.SITH_SAS_ROOT_DIR_ID,
name=seq("Album "),
)
picture_recipe = Recipe(
Picture,
is_in_sas=True,
is_folder=False,
is_moderated=True,
parent=foreign_key(album_recipe),
name=seq("Picture "),
)
"""A SAS Picture fixture.
+57 -68
View File
@@ -15,8 +15,6 @@
from __future__ import annotations
import contextlib
from io import BytesIO
from pathlib import Path
from typing import ClassVar, Self
@@ -30,7 +28,7 @@ from django.utils.translation import gettext_lazy as _
from PIL import Image
from core.models import Notification, SithFile, User
from core.utils import exif_auto_rotate, resize_image
from core.utils import resize_image
class SasFile(SithFile):
@@ -92,88 +90,75 @@ class Picture(SasFile):
objects = SASPictureManager.from_queryset(PictureQuerySet)()
@property
def is_vertical(self):
with open(settings.MEDIA_ROOT / self.file.name, "rb") as f:
im = Image.open(BytesIO(f.read()))
(w, h) = im.size
return (w / h) < 1
def get_download_url(self):
return reverse("sas:download", kwargs={"picture_id": self.id})
return reverse(
"sas:download",
kwargs={"picture_id": self.id},
query={"date": int(self.updated_at.timestamp())},
)
def get_download_compressed_url(self):
return reverse("sas:download_compressed", kwargs={"picture_id": self.id})
return reverse(
"sas:download_compressed",
kwargs={"picture_id": self.id},
query={"date": int(self.updated_at.timestamp())},
)
def get_download_thumb_url(self):
return reverse("sas:download_thumb", kwargs={"picture_id": self.id})
return reverse(
"sas:download_thumb",
kwargs={"picture_id": self.id},
query={"date": int(self.updated_at.timestamp())},
)
def get_absolute_url(self):
return reverse("sas:picture", kwargs={"picture_id": self.id})
def generate_thumbnails(self):
im = Image.open(BytesIO(self.file.read()))
with contextlib.suppress(Exception):
im = exif_auto_rotate(im)
def generate_thumbnails(
self, *, img: Image.Image | None = None, save: bool = False
):
"""Generate the thumbnail and the compressed version of this picture.
Args:
img: if given, this will be used to generate
all three images (file, compressed, thumbnail).
Else, `self.file` will be used
save: if True, save the instance in database.
"""
img = img or Image.open(self.file)
extension = self.mime_type.split("/")[-1]
previous_files = [
f.name for f in (self.file, self.thumbnail, self.compressed) if f
]
# convert the compressed image and the thumbnail into webp
# The original image keeps its original type, because it's not
# meant to be shown on the website, but rather to keep the real image
# for less frequent cases (like downloading the pictures of an user)
extension = self.mime_type.split("/")[-1]
# for less frequent cases (like downloading the pictures of a user)
# the HD version of the image doesn't need to be optimized, because :
# - it isn't frequently queried
# - optimizing large images takes a lot time, which greatly hinders the UX
# - optimizing large images takes a lot of time, which greatly hinders the UX
# - photographers usually already optimize their images
file = resize_image(im, max(im.size), extension, optimize=False)
thumb = resize_image(im, 200, "webp")
compressed = resize_image(im, 1200, "webp")
new_extension_name = str(Path(self.name).with_suffix(".webp"))
self.file = file
self.file.name = self.name
self.thumbnail = thumb
self.thumbnail.name = new_extension_name
self.compressed = compressed
self.compressed.name = new_extension_name
file = resize_image(img, max(img.size), extension, optimize=False)
self.file.save(self.name, file, save=False)
thumbnail = resize_image(img, 200, "webp")
self.thumbnail.save(new_extension_name, thumbnail, save=False)
compressed = resize_image(img, 1200, "webp")
self.compressed.save(new_extension_name, compressed, save=save)
# once the new images have been saved, delete the previous ones.
# The deletion of old files is done after, so that if anything goes
# during the whole process, no data will be lost.
for filename in previous_files:
self.file.storage.delete(filename)
def rotate(self, degree):
for attr in ["file", "compressed", "thumbnail"]:
name = self.__getattribute__(attr).name
with open(settings.MEDIA_ROOT / name, "r+b") as file:
if file:
im = Image.open(BytesIO(file.read()))
file.seek(0)
im = im.rotate(degree, expand=True)
im.save(
fp=file,
format=self.mime_type.split("/")[-1].upper(),
quality=90,
optimize=True,
progressive=True,
)
def rotate(self, degree: int | float):
"""Rotate this picture and update its thumbnails accordingly.
def get_next(self):
if self.is_moderated:
pictures_qs = self.parent.children.filter(
is_moderated=True,
asked_for_removal=False,
is_folder=False,
id__gt=self.id,
)
else:
pictures_qs = Picture.objects.filter(id__gt=self.id, is_moderated=False)
return pictures_qs.order_by("id").first()
def get_previous(self):
if self.is_moderated:
pictures_qs = self.parent.children.filter(
is_moderated=True,
asked_for_removal=False,
is_folder=False,
id__lt=self.id,
)
else:
pictures_qs = Picture.objects.filter(id__lt=self.id, is_moderated=False)
return pictures_qs.order_by("-id").first()
Args:
degree: the rotation angle, in degree, counter-clockwise
"""
img = Image.open(self.file).rotate(degree)
self.generate_thumbnails(img=img, save=True)
class AlbumQuerySet(models.QuerySet):
@@ -239,7 +224,11 @@ class Album(SasFile):
return reverse("sas:album", kwargs={"album_id": self.id})
def get_download_url(self):
return reverse("sas:album_preview", kwargs={"album_id": self.id})
return reverse(
"sas:album_preview",
kwargs={"album_id": self.id},
query={"date": int(self.updated_at.timestamp())},
)
def generate_thumbnail(self):
p = self.children_pictures.order_by("?").first()
+9 -1
View File
@@ -70,7 +70,15 @@ class PictureFilterSchema(FilterSchema):
class PictureSchema(ModelSchema):
class Meta:
model = Picture
fields = ["id", "name", "date", "size", "is_moderated", "asked_for_removal"]
fields = [
"id",
"name",
"date",
"updated_at",
"size",
"is_moderated",
"asked_for_removal",
]
owner: UserProfileSchema
sas_url: str
+18 -3
View File
@@ -1,4 +1,4 @@
import { paginated } from "#core:utils/api.ts";
import { paginated } from "#core:utils/api";
import {
type PictureSchema,
type PicturesFetchPicturesData,
@@ -35,8 +35,23 @@ document.addEventListener("alpine:init", () => {
// biome-ignore lint/style/useNamingConvention: from python api
query: { users_identified: [config.userId] },
} as PicturesFetchPicturesData);
localStorage.setItem(localStorageInvalidationKey, config.nbPictures.toString());
localStorage.setItem(localStorageKey, JSON.stringify(pictures));
try {
localStorage.setItem(localStorageInvalidationKey, config.nbPictures.toString());
localStorage.setItem(localStorageKey, JSON.stringify(pictures));
} catch {
// an exception is raised if the localstorage is entirely filled
// so just delete all cached user pictures.
// A cache hit is not worth the page breaking.
Object.keys(localStorage)
.filter(
(key) =>
key.startsWith("user") &&
(key.endsWith("Pictures") || key.endsWith("PicturesNumber")),
)
.forEach((key) => {
localStorage.removeItem(key);
});
}
return pictures;
},
+38 -11
View File
@@ -1,7 +1,7 @@
import type TomSelect from "tom-select";
import type { UserAjaxSelect } from "#core:core/components/ajax-select-index.ts";
import { paginated } from "#core:utils/api.ts";
import { History } from "#core:utils/history.ts";
import type { UserAjaxSelect } from "#core:core/components/ajax-select-index";
import { paginated } from "#core:utils/api";
import { History } from "#core:utils/history";
import {
type IdentifiedUserSchema,
type ModerationRequestSchema,
@@ -14,6 +14,7 @@ import {
picturesFetchPictures,
picturesIdentifyUsers,
picturesModeratePicture,
picturesRotatePicture,
type UserProfileSchema,
usersidentifiedDeleteRelation,
} from "#openapi";
@@ -28,18 +29,32 @@ class PictureWithIdentifications {
identificationsLoading = false;
moderationLoading = false;
id: number;
// biome-ignore lint/style/useNamingConvention: api is in snake_case
compressed_url: string;
compressedUrl: string = "";
thumbUrl: string = "";
fullSizeUrl: string = "";
moderationRequests: ModerationRequestSchema[] = null;
constructor(picture: PictureSchema) {
Object.assign(this, picture);
this.compressedUrl = picture.compressed_url;
this.thumbUrl = picture.thumb_url;
this.fullSizeUrl = picture.full_size_url;
}
static fromPicture(picture: PictureSchema): PictureWithIdentifications {
return new PictureWithIdentifications(picture);
}
rebuildUrls(date: Date) {
const buildUrl = (url: string) => {
const base = url.split("?", 1)[0];
return `${base}?date=${date.getTime().toString()}`;
};
this.compressedUrl = buildUrl(this.compressedUrl);
this.thumbUrl = buildUrl(this.thumbUrl);
this.fullSizeUrl = buildUrl(this.fullSizeUrl);
}
/**
* If not already done, fetch the users identified on this picture and
* populate the identifications field
@@ -82,12 +97,25 @@ class PictureWithIdentifications {
this.moderationLoading = false;
}
async rotate(direction: "left" | "right") {
this.imageLoading = true;
const res = await picturesRotatePicture({
// biome-ignore lint/style/useNamingConvention: api is snake case
path: { picture_id: this.id, direction: direction },
});
// urls returned by the api include a timestamp for cache busting
this.fullSizeUrl = res.data.full_size_url;
this.compressedUrl = res.data.compressed_url;
this.thumbUrl = res.data.thumb_url;
this.imageLoading = false;
}
/**
* Preload the photo and the identifications
*/
async preload(): Promise<void> {
const img = new Image();
img.src = this.compressed_url;
img.src = this.compressedUrl;
if (!img.complete) {
this.imageLoading = true;
img.addEventListener("load", () => {
@@ -140,7 +168,8 @@ document.addEventListener("alpine:init", () => {
// biome-ignore lint/style/useNamingConvention: api is in snake_case
full_size_url: "",
owner: "",
date: new Date(),
// biome-ignore lint/style/useNamingConvention: api is in snake_case
created_at: new Date(),
identifications: [] as IdentifiedUserSchema[],
},
/**
@@ -291,10 +320,8 @@ document.addEventListener("alpine:init", () => {
async submitIdentification(): Promise<void> {
const widget: TomSelect = this.selector.widget;
await picturesIdentifyUsers({
path: {
// biome-ignore lint/style/useNamingConvention: api is in snake_case
picture_id: this.currentPicture.id,
},
// biome-ignore lint/style/useNamingConvention: api is in snake_case
path: { picture_id: this.currentPicture.id },
body: widget.items.map((i: string) => Number.parseInt(i, 10)),
});
// refresh the identified users list
+22 -25
View File
@@ -235,37 +235,34 @@
>.tools {
flex: 1;
.btn {
background-color: $primary-neutral-light-color;
display: flex;
justify-content: center;
align-items: center;
padding: 0;
color: black;
width: 40px;
height: 40px;
font-size: 20px;
>div>div {
>a.btn {
background-color: $primary-neutral-light-color;
display: flex;
justify-content: center;
align-items: center;
padding: 0;
color: black;
width: 40px;
height: 40px;
font-size: 20px;
&:hover {
background-color: #aaa;
}
&:hover {
background-color: #aaa;
}
}
>a.text.danger {
color: red;
a.text.danger {
color: red;
&:hover {
color: darkred;
}
&:hover {
color: darkred;
}
}
&.buttons {
display: flex;
flex-direction: row;
gap: 5px;
}
.buttons {
display: flex;
flex-direction: row;
gap: 5px;
}
}
}
+27 -12
View File
@@ -1,9 +1,9 @@
{% extends "core/base.jinja" %}
{%- block additional_css -%}
<link defer rel="stylesheet" href="{{ static('bundled/core/components/ajax-select-index.css') }}">
<link defer rel="stylesheet" href="{{ static('core/components/ajax-select.scss') }}">
<link defer rel="stylesheet" href="{{ static('sas/css/picture.scss') }}">
<link rel="stylesheet" href="{{ static('bundled/core/components/ajax-select-index.css') }}">
<link rel="stylesheet" href="{{ static('core/components/ajax-select.scss') }}">
<link rel="stylesheet" href="{{ static('sas/css/picture.scss') }}">
{%- endblock -%}
{%- block additional_js -%}
@@ -84,7 +84,7 @@
<div class="main">
<div class="photo" :aria-busy="currentPicture.imageLoading">
<img
:src="currentPicture.compressed_url"
:src="currentPicture.compressedUrl"
:alt="currentPicture.name"
id="main-picture"
x-ref="mainPicture"
@@ -100,7 +100,7 @@
<span
x-text="Intl.DateTimeFormat(
'{{ LANGUAGE_CODE }}', {dateStyle: 'long'}
).format(new Date(currentPicture.date))"
).format(Date.parse(currentPicture.date))"
>
</span>
</div>
@@ -115,23 +115,38 @@
<h5>{% trans %}Tools{% endtrans %}</h5>
<div>
<div>
<a class="text" :href="currentPicture.full_size_url">
<a class="text" :href="currentPicture.fullSizeUrl">
{% trans %}HD version{% endtrans %}
</a>
<a class="text danger " :href="currentPicture.report_url">
{% trans %}Ask for removal{% endtrans %}
</a>
</div>
<div class="buttons">
<div
class="buttons"
x-show="{{ user.has_perm("sas.change_sasfile")|tojson }} || currentPicture.owner.id === {{ user.id }}"
>
<a
class="btn btn-no-text"
:href="currentPicture.edit_url"
x-show="{{ user.has_perm("sas.change_sasfile")|tojson }} || currentPicture.owner.id === {{ user.id }}"
:disabled="currentPicture.imageLoading"
>
<i class="fa-regular fa-pen-to-square edit-action"></i>
</a>
<a class="btn btn-no-text" href="?rotate_left"><i class="fa-solid fa-rotate-left"></i></a>
<a class="btn btn-no-text" href="?rotate_right"><i class="fa-solid fa-rotate-right"></i></a>
<button
class="btn btn-no-text"
@click="currentPicture.rotate('left')"
:disabled="currentPicture.imageLoading"
>
<i class="fa-solid fa-rotate-left"></i>
</button>
<button
class="btn btn-no-text"
@click="currentPicture.rotate('right')"
:disabled="currentPicture.imageLoading"
>
<i class="fa-solid fa-rotate-right"></i>
</button>
</div>
</div>
</div>
@@ -146,7 +161,7 @@
@keyup.left.window="currentPicture = previousPicture"
@click="currentPicture = previousPicture"
>
<img :src="previousPicture.thumb_url" alt="{% trans %}Previous picture{% endtrans %}"/>
<img :src="previousPicture.thumbUrl" alt="{% trans %}Previous picture{% endtrans %}"/>
<div class="overlay">←</div>
</div>
</template>
@@ -157,7 +172,7 @@
@keyup.right.window="currentPicture = nextPicture"
@click="currentPicture = nextPicture"
>
<img :src="nextPicture.thumb_url" alt="{% trans %}Previous picture{% endtrans %}"/>
<img :src="nextPicture.thumbUrl" alt="{% trans %}Previous picture{% endtrans %}"/>
<div class="overlay">→</div>
</div>
</template>
+38
View File
@@ -1,6 +1,11 @@
from io import BytesIO
from pathlib import Path
import pytest
from django.core.files.base import ContentFile
from django.test import TestCase
from model_bakery import baker
from PIL import Image
from core.baker_recipes import old_subscriber_user, subscriber_user
from core.models import User
@@ -67,3 +72,36 @@ def test_identifications_viewable_by_user():
assert list(picture.people.viewable_by(identifications[1].user)) == [
identifications[1]
]
@pytest.mark.django_db
@pytest.mark.parametrize("save", [True, False])
@pytest.mark.parametrize("initially_saved", [True, False])
@pytest.mark.parametrize("pass_img_kwarg", [True, False])
def test_generate_thumbnail(save, initially_saved, pass_img_kwarg):
"""Test that Picture.generate_thumbnails works properly"""
image = Image.new("RGB", (2, 1))
image.putdata([(255, 0, 0), (0, 255, 0)])
buffer = BytesIO()
image.save(buffer, format="PNG")
file = ContentFile(buffer.getvalue(), "img.png")
picture: Picture = picture_recipe.prepare(
file=file,
name=file.name,
mime_type="image/png",
_save_related=True,
)
if initially_saved:
picture.save()
picture.generate_thumbnails(img=image if pass_img_kwarg else None, save=save)
storage = picture.file.storage
for f in picture.file, picture.compressed, picture.thumbnail:
# the tested picture is alone in its album,
# so there should be a single file in each folder
assert storage.exists(f.name)
_dirs, files = storage.listdir(str(Path(f.path).parent))
assert files == [Path(f.name).name]
new_img = Image.open(picture.file)
assert new_img.get_flattened_data() == image.get_flattened_data()
assert Image.open(picture.thumbnail).size == (200, 100)
assert Image.open(picture.compressed).size == (1200, 600)
+70 -1
View File
@@ -12,19 +12,23 @@
# OR WITHIN THE LOCAL FILE "LICENSE"
#
#
from typing import Callable
from typing import Callable, Literal
from unittest.mock import patch
import pytest
from bs4 import BeautifulSoup
from django.conf import settings
from django.core.cache import cache
from django.core.files.base import ContentFile
from django.test import Client, TestCase
from django.urls import reverse
from model_bakery import baker
from PIL import Image
from pytest_django.asserts import assertHTMLEqual, assertInHTML, assertRedirects
from core.baker_recipes import old_subscriber_user, subscriber_user
from core.models import Group, User
from core.utils import RED_PIXEL_PNG
from sas.baker_recipes import picture_recipe
from sas.models import Album, Picture
@@ -162,6 +166,71 @@ class TestAlbumUpload:
assert not album.children.exists()
@pytest.mark.django_db
class TestPictureRotation:
@pytest.fixture
def picture(self) -> Picture:
return picture_recipe.make(
parent_id=settings.SITH_SAS_ROOT_DIR_ID,
file=ContentFile(name="foo.png", content=RED_PIXEL_PNG),
compressed=ContentFile(name="foo.png", content=RED_PIXEL_PNG),
thumbnail=ContentFile(name="foo.png", content=RED_PIXEL_PNG),
)
@pytest.mark.parametrize(
"user",
[
None,
lambda: baker.make(User),
subscriber_user.make,
old_subscriber_user.make,
],
)
def test_permission_denied(
self, client: Client, picture: Picture, user: Callable[[], User] | None
):
if user:
client.force_login(user())
url = reverse(
"api:rotate_picture", kwargs={"picture_id": picture.id, "direction": "left"}
)
response = client.post(url)
assert response.status_code == 403 if user else 401
@pytest.mark.parametrize(
"user",
[
lambda: baker.make(User, is_superuser=True),
lambda: baker.make(
User, groups=[Group.objects.get(pk=settings.SITH_GROUP_SAS_ADMIN_ID)]
),
],
)
@pytest.mark.parametrize(("direction", "angle"), [("left", 90), ("right", 270)])
def test_rotation(
self,
client: Client,
picture: Picture,
user: Callable[[], User],
direction: Literal["left", "right"],
angle: Literal[90, 270],
):
client.force_login(user())
url = reverse(
"api:rotate_picture",
kwargs={"picture_id": picture.id, "direction": direction},
)
with (
patch.object(Image.Image, "rotate") as mocked_rotate,
patch.object(Picture, "generate_thumbnails") as mocked_thumb,
):
response = client.post(url)
assert response.status_code == 200
mocked_rotate.assert_called_once_with(angle)
mocked_thumb.assert_called_once()
class TestSasModeration(TestCase):
@classmethod
def setUpTestData(cls):
-8
View File
@@ -97,14 +97,6 @@ class PictureView(CanViewMixin, DetailView):
pk_url_kwarg = "picture_id"
template_name = "sas/picture.jinja"
def get(self, request, *args, **kwargs):
self.object = self.get_object()
if "rotate_right" in request.GET:
self.object.rotate(270)
if "rotate_left" in request.GET:
self.object.rotate(90)
return super().get(request, *args, **kwargs)
def get_context_data(self, **kwargs):
return super().get_context_data(**kwargs) | {
"album": Album.objects.get(children=self.object)
+4 -18
View File
@@ -523,22 +523,14 @@ SITH_SUBSCRIPTIONS = {
"membre-honoraire": {"name": _("Honorary member"), "price": 0, "duration": 666},
"assidu": {"name": _("Assidu member"), "price": 0, "duration": 2},
"amicale/doceo": {"name": _("Amicale/DOCEO member"), "price": 0, "duration": 2},
"reseau-ut": {"name": _("UT network member"), "price": 0, "duration": 1},
"crous": {"name": _("CROUS member"), "price": 0, "duration": 2},
"sbarro/esta": {"name": _("Sbarro/ESTA member"), "price": 15, "duration": 2},
"un-semestre-welcome": {
"name": _("One semester Welcome Week"),
"reseau-ut (hors UTC)": {
"name": _("UT network member (excluding UTC)"),
"price": 0,
"duration": 1,
},
"benevoles-euroks": {"name": _("Eurok's volunteer"), "price": 5, "duration": 0.1},
"six-semaines-essai": {
"name": _("Six weeks for free"),
"price": 0,
"duration": 0.23,
},
"crous": {"name": _("CROUS member"), "price": 0, "duration": 2},
"sbarro/esta": {"name": _("Sbarro/ESTA member"), "price": 15, "duration": 2},
"un-jour": {"name": _("One day"), "price": 0, "duration": 0.00555333},
"membre-staff-ga": {"name": _("GA staff member"), "price": 1, "duration": 0.076},
# Discount subscriptions
"un-semestre-reduction": {
"name": _("One semester (-20%)"),
@@ -565,12 +557,6 @@ SITH_SUBSCRIPTIONS = {
"price": 28,
"duration": 6,
},
# CA special offer
"un-an-offert-CA": {
"name": _("One year for free(CA offer)"),
"price": 0,
"duration": 2,
},
# To be completed....
}
+6 -2
View File
@@ -79,7 +79,6 @@ class SubscriptionNewUserForm(SubscriptionForm):
"""
allowed_payment_methods = ["CARD", "CASH"]
template_name = "subscription/forms/create_new_user.jinja"
__user_fields = forms.fields_for_model(
User,
@@ -121,6 +120,12 @@ class SubscriptionNewUserForm(SubscriptionForm):
email=self.cleaned_data.get("email"),
date_of_birth=self.cleaned_data.get("date_of_birth"),
)
if self.errors:
# don't bother generating username, password and other data.
# The form validation failed anyway, so using a dummy User
# (just for Subscription.clean not to crash) is enough
self.instance.member = member
return super().clean()
if self.cleaned_data.get("subscription_type") in [
"un-semestre",
"deux-semestres",
@@ -153,7 +158,6 @@ class SubscriptionNewUserForm(SubscriptionForm):
class SubscriptionExistingUserForm(SubscriptionForm):
"""Form to add a subscription to an existing user."""
template_name = "subscription/forms/create_existing_user.jinja"
required_css_class = "required"
birthdate = forms.fields_for_model(
@@ -1,38 +1,42 @@
import { userFetchUser } from "#openapi";
document.addEventListener("alpine:init", () => {
Alpine.data("existing_user_subscription_form", () => ({
loading: false,
profileFragment: "" as string,
Alpine.data("existing_user_subscription_form", () => ({
loading: false,
selectedUser: "",
profileFragment: "",
dateOfBirth: "",
dateOfBirthHidden: true,
async init() {
const userSelect = document.getElementById("id_member") as HTMLSelectElement;
userSelect.addEventListener("change", async () => {
await this.loadProfile(Number.parseInt(userSelect.value, 10));
});
await this.loadProfile(Number.parseInt(userSelect.value, 10));
},
init() {
this.$watch("selectedUser", async () => {
await this.loadProfile(Number.parseInt(this.selectedUser, 10));
});
async loadProfile(userId: number) {
const birthdayInput = document.getElementById("id_birthdate") as HTMLInputElement;
if (!Number.isInteger(userId)) {
this.profileFragment = "";
birthdayInput.hidden = true;
return;
}
this.loading = true;
const [miniProfile, userInfos] = await Promise.all([
fetch(`/user/${userId}/mini/`),
// biome-ignore lint/style/useNamingConvention: api is snake_case
userFetchUser({ path: { user_id: userId } }),
]);
this.profileFragment = await miniProfile.text();
// If the user has no birthdate yet, show the form input
// to fill this info.
// Else keep the input hidden and change its value to the user birthdate
birthdayInput.value = userInfos.data.date_of_birth;
birthdayInput.hidden = userInfos.data.date_of_birth !== null;
this.loading = false;
},
}));
});
this.$nextTick(() => {
// Force to detect the initial value
this.selectedUser = this.$refs.userSelect.widget.getValue();
});
},
async loadProfile(userId: number) {
if (!Number.isInteger(userId)) {
this.profileFragment = "";
this.dateOfBirth = "";
this.dateOfBirthHidden = true;
return;
}
this.loading = true;
const [miniProfile, userInfos] = await Promise.all([
fetch(`/user/${userId}/mini/`),
// biome-ignore lint/style/useNamingConvention: api is snake_case
userFetchUser({ path: { user_id: userId } }),
]);
this.profileFragment = await miniProfile.text();
// If the user has no birthdate yet, show the form input
// to fill this info.
// Else keep the input hidden and change its value to the user birthdate
this.dateOfBirth = userInfos.data.date_of_birth;
this.dateOfBirthHidden = userInfos.data.date_of_birth !== null;
this.loading = false;
},
}));
@@ -5,7 +5,8 @@
margin-top: 0;
}
fieldset p:first-of-type, & > p:first-of-type {
fieldset p:first-of-type,
&>p:first-of-type {
margin-top: 0;
}
@@ -24,7 +25,7 @@
fieldset {
flex: 0 1 auto;
p:has(input[hidden]) {
.form-group:has(input[hidden]) {
// when the input is hidden, hide the whole label+input+help text group
display: none;
}
@@ -1,28 +0,0 @@
{% load static %}
{% load i18n %}
<div x-data="existing_user_subscription_form" class="form-content existing-user">
<fieldset>
{{ errors }}
{% for field, errors in fields %}
<p{% with classes=field.css_classes %}{% if classes %} class="{{ classes }}"{% endif %}{% endwith %}>
{{ field.label_tag }}
{{ field }}
{% if field.help_text %}
<span class="helptext">{{ field.help_text }}</span>
{% endif %}
</p>
{% if field.name == "payment_method" %}
<i>
{% blocktranslate %}If the subscription is done using the AE account, you must also click it on the AE counter.{% endblocktranslate %}
</i>
{% endif %}
{% endfor %}
</fieldset>
<div
id="subscription-form-user-mini-profile"
x-html="profileFragment"
:aria-busy="loading"
></div>
</div>
@@ -1 +0,0 @@
{{ form.as_p }}
@@ -0,0 +1,41 @@
<form
hx-post="{{ url("subscription:fragment-existing-user") }}"
hx-target="this"
hx-disabled-elt="find input[type='submit']"
hx-swap="outerHTML"
>
{% csrf_token %}
<div x-data="existing_user_subscription_form" class="form-content existing-user">
<fieldset>
{{ form.non_field_errors() }}
<div class="form-group">
{{ form.member.label_tag() }}
{{ form.member|add_attr("x-ref=userSelect,x-model=selectedUser") }}
</div>
<div class="form-group">
{{ form.birthdate.label_tag() }}
{{ form.birthdate|add_attr(":value=dateOfBirth,:hidden=dateOfBirthHidden,:type=dateOfBirthHidden ? 'hidden' : 'date'") }}
<span class="helptext">{{ form.birthdate.help_text }}</span>
</div>
<div class="form-group">{{ form.subscription_type.as_field_group() }}</div>
<div class="form-group">
{{ form.payment_method.as_field_group() }}
<i>
{% trans trimmed %}
If the subscription is done using the AE account,
you must also click it on the AE counter.
{% endtrans %}
</i>
</div>
<div class="form-group">{{ form.location.as_field_group() }}</div>
</fieldset>
<div
id="subscription-form-user-mini-profile"
x-html="profileFragment"
:aria-busy="loading"
></div>
</div>
<input type="submit" value="{% trans %}Save{% endtrans %}">
</form>
@@ -1,5 +1,5 @@
<form
hx-post="{{ post_url }}"
hx-post="{{ url("subscription:fragment-new-user") }}"
hx-target="this"
hx-disabled-elt="find input[type='submit']"
hx-swap="outerHTML"
@@ -18,22 +18,14 @@
<link rel="stylesheet" href="{{ static("subscription/css/subscription.scss") }}">
{% endblock %}
{% macro form_fragment(form_object, post_url) %}
{# Include the form fragment inside a with block,
in order to inject the right form in the right place #}
{% with form=form_object, post_url=post_url %}
{% include "subscription/fragments/creation_form.jinja" %}
{% endwith %}
{% endmacro %}
{% block content %}
<h3>{% trans %}New subscription{% endtrans %}</h3>
<ui-tab-group id="subscription-form">
<ui-tab title="{% trans %}Existing member{% endtrans %}" active>
{{ form_fragment(existing_user_form, existing_user_post_url) }}
{{ existing_user_fragment }}
</ui-tab>
<ui-tab title="{% trans %}New member{% endtrans %}">
{{ form_fragment(new_user_form, new_user_post_url) }}
{{ new_user_fragment }}
</ui-tab>
</ui-tab-group>
{% endblock %}
+7 -10
View File
@@ -12,7 +12,6 @@ from django.urls import reverse
from django.utils.timezone import localdate
from model_bakery import baker
from pytest_django.asserts import assertRedirects
from pytest_django.fixtures import SettingsWrapper
from core.baker_recipes import board_user, old_subscriber_user, subscriber_user
from core.models import Group, User
@@ -26,9 +25,7 @@ from subscription.models import Subscription
"user_factory",
[old_subscriber_user.make, lambda: baker.make(User)],
)
def test_form_existing_user_valid(
user_factory: Callable[[], User], settings: SettingsWrapper
):
def test_form_existing_user_valid(user_factory: Callable[[], User]):
"""Test `SubscriptionExistingUserForm`"""
user = user_factory()
user.date_of_birth = date(year=1967, month=3, day=14)
@@ -48,7 +45,7 @@ def test_form_existing_user_valid(
@pytest.mark.django_db
def test_form_existing_user_with_birthdate(settings: SettingsWrapper):
def test_form_existing_user_with_birthdate():
"""Test `SubscriptionExistingUserForm`"""
user = baker.make(User, date_of_birth=None)
data = {
@@ -70,7 +67,7 @@ def test_form_existing_user_with_birthdate(settings: SettingsWrapper):
@pytest.mark.django_db
def test_form_existing_user_invalid(settings: SettingsWrapper):
def test_form_existing_user_invalid():
"""Test `SubscriptionExistingUserForm`, with users that shouldn't subscribe."""
user = subscriber_user.make()
# make sure the current subscription will end in a long time
@@ -91,7 +88,7 @@ def test_form_existing_user_invalid(settings: SettingsWrapper):
@pytest.mark.django_db
def test_form_new_user(settings: SettingsWrapper):
def test_form_new_user():
data = {
"first_name": "John",
"last_name": "Doe",
@@ -121,7 +118,7 @@ def test_form_new_user(settings: SettingsWrapper):
"subscription_type",
["un-semestre", "deux-semestres", "cursus-tronc-commun", "cursus-branche"],
)
def test_form_set_new_user_as_student(settings: SettingsWrapper, subscription_type):
def test_form_set_new_user_as_student(subscription_type):
"""Test that new users have the student role by default."""
data = {
"first_name": "John",
@@ -165,7 +162,7 @@ def test_page_access_with_get_data(client: Client):
@pytest.mark.django_db
def test_submit_form_existing_user(client: Client, settings: SettingsWrapper):
def test_submit_form_existing_user(client: Client):
client.force_login(
baker.make(
User,
@@ -196,7 +193,7 @@ def test_submit_form_existing_user(client: Client, settings: SettingsWrapper):
@pytest.mark.django_db
def test_submit_form_new_user(client: Client, settings: SettingsWrapper):
def test_submit_form_new_user(client: Client):
client.force_login(
baker.make(
User,
+14 -19
View File
@@ -23,6 +23,7 @@ from django.utils.translation import gettext_lazy as _
from django.views.generic import CreateView, DetailView, TemplateView
from django.views.generic.edit import FormView
from core.views import FragmentMixin, UseFragmentsMixin
from core.views.group import PermissionGroupsUpdateView
from subscription.forms import (
SelectionDateForm,
@@ -32,24 +33,9 @@ from subscription.forms import (
from subscription.models import Subscription
class NewSubscription(PermissionRequiredMixin, TemplateView):
template_name = "subscription/subscription.jinja"
permission_required = "subscription.add_subscription"
def get_context_data(self, **kwargs):
return super().get_context_data(**kwargs) | {
"existing_user_form": SubscriptionExistingUserForm(
initial={"member": self.request.GET.get("member")}
),
"new_user_form": SubscriptionNewUserForm(),
"existing_user_post_url": reverse("subscription:fragment-existing-user"),
"new_user_post_url": reverse("subscription:fragment-new-user"),
}
class CreateSubscriptionFragment(PermissionRequiredMixin, CreateView):
template_name = "subscription/fragments/creation_form.jinja"
class CreateSubscriptionFragment(PermissionRequiredMixin, FragmentMixin, CreateView):
permission_required = "subscription.add_subscription"
object = None
def get_success_url(self):
return reverse(
@@ -61,14 +47,14 @@ class CreateSubscriptionExistingUserFragment(CreateSubscriptionFragment):
"""Create a subscription for a user who already exists."""
form_class = SubscriptionExistingUserForm
extra_context = {"post_url": reverse_lazy("subscription:fragment-existing-user")}
template_name = "subscription/fragments/creation_form_existing_user.jinja"
class CreateSubscriptionNewUserFragment(CreateSubscriptionFragment):
"""Create a subscription for a user who doesn't exist yet."""
form_class = SubscriptionNewUserForm
extra_context = {"post_url": reverse_lazy("subscription:fragment-new-user")}
template_name = "subscription/fragments/creation_form_new_user.jinja"
def form_valid(self, form):
res = super().form_valid(form)
@@ -83,6 +69,15 @@ class CreateSubscriptionNewUserFragment(CreateSubscriptionFragment):
return res
class NewSubscription(PermissionRequiredMixin, UseFragmentsMixin, TemplateView):
template_name = "subscription/subscription.jinja"
permission_required = "subscription.add_subscription"
fragments = {
"new_user_fragment": CreateSubscriptionNewUserFragment,
"existing_user_fragment": CreateSubscriptionExistingUserFragment,
}
class SubscriptionCreatedFragment(PermissionRequiredMixin, DetailView):
template_name = "subscription/fragments/creation_success.jinja"
permission_required = "subscription.add_subscription"
+3 -10
View File
@@ -1,5 +1,4 @@
import { parse, resolve } from "node:path";
import inject from "@rollup/plugin-inject";
import { glob } from "glob";
import { visualizer } from "rollup-plugin-visualizer";
import {
@@ -81,14 +80,8 @@ export default defineConfig((config: UserConfig) => {
resolve: {
alias: getAliases(),
},
plugins: [
inject({
// biome-ignore lint/style/useNamingConvention: that's how it's called
Alpine: "alpinejs",
htmx: "htmx.org",
}),
visualizer({ filename: ".bundle-size-report.html" }) as PluginOption,
],
// biome-ignore lint/style/useNamingConvention: that's how it's called
inject: { Alpine: "alpinejs", htmx: "htmx.org" },
plugins: [visualizer({ filename: ".bundle-size-report.html" }) as PluginOption],
} satisfies UserConfig;
});