Compare commits

..

1 Commits

Author SHA1 Message Date
dependabot[bot] a11cc073ed [UPDATE] Update sphinx requirement from <6,>=5 to >=5.3.0,<6
Updates the requirements on [sphinx](https://github.com/sphinx-doc/sphinx) to permit the latest version.
- [Release notes](https://github.com/sphinx-doc/sphinx/releases)
- [Changelog](https://github.com/sphinx-doc/sphinx/blob/v5.3.0/CHANGES)
- [Commits](https://github.com/sphinx-doc/sphinx/compare/v5.0.0...v5.3.0)

---
updated-dependencies:
- dependency-name: sphinx
  dependency-version: 5.3.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-04 09:35:52 +00:00
55 changed files with 697 additions and 825 deletions
+21 -15
View File
@@ -12,7 +12,7 @@ runs:
steps:
- name: Install apt packages
if: ${{ inputs.full == 'true' }}
uses: awalsh128/cache-apt-pkgs-action@v1.6.0
uses: awalsh128/cache-apt-pkgs-action@v1.4.3
with:
packages: gettext
version: 1.0 # increment to reset cache
@@ -23,29 +23,26 @@ 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@v6
uses: actions/setup-python@v5
with:
python-version-file: ".python-version"
- 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
uses: actions/cache/restore@v4
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 --locked
run: uv sync
shell: bash
- name: Install Xapian
@@ -53,6 +50,15 @@ 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@v6
- uses: actions/setup-python@v6
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
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@v6
uses: actions/checkout@v4
- 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@v7
uses: actions/upload-artifact@v4
with:
name: coverage-report-${{ matrix.pytest-mark }}
path: coverage_report
+3 -1
View File
@@ -14,7 +14,7 @@ jobs:
steps:
- name: SSH Remote Commands
uses: appleboy/ssh-action@v1.2.5
uses: appleboy/ssh-action@v1.1.0
with:
# Proxy
proxy_host : ${{secrets.PROXY_HOST}}
@@ -29,6 +29,8 @@ 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@v6
- uses: actions/checkout@v4
- uses: ./.github/actions/setup_project
- run: echo "cache_id=$(date --utc '+%V')" >> $GITHUB_ENV
- uses: actions/cache@v5
- uses: actions/cache@v3
with:
key: mkdocs-material-${{ env.cache_id }}
path: .cache
+3 -1
View File
@@ -13,7 +13,7 @@ jobs:
steps:
- name: SSH Remote Commands
uses: appleboy/ssh-action@v1.2.5
uses: appleboy/ssh-action@v1.1.0
with:
# Proxy
proxy_host : ${{secrets.PROXY_HOST}}
@@ -28,6 +28,8 @@ 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 -1
View File
@@ -26,9 +26,10 @@
{% 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 %}
+13 -17
View File
@@ -3,7 +3,6 @@
#news {
display: flex;
gap: 1em;
@media (max-width: 800px) {
flex-direction: column;
@@ -27,14 +26,12 @@
}
h3 {
--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;
background: $second-color;
box-shadow: $shadow-color 1px 1px 1px;
padding: 0.4em;
margin: 0 0 0.5em 0;
text-transform: uppercase;
font-size: 17px;
border-radius: 10px;
&:not(:first-of-type) {
margin: 2em 0 1em 0;
@@ -42,11 +39,12 @@
.feed {
float: right;
color: #e25512;
color: #f26522;
}
}
@media screen and (max-width: $small-devices) {
#left_column,
#right_column {
flex: 100%;
@@ -59,7 +57,6 @@
max-height: 600px;
overflow-y: scroll;
overflow-x: clip;
margin-top: 1em;
#load-more-news-button {
text-align: center;
@@ -79,11 +76,15 @@
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: 1em;
padding-bottom: 1em;
h4 {
margin-left: 5px;
@@ -120,8 +121,6 @@
}
#birthdays_content {
box-shadow: $shadow-color 1px 1px 1px;
padding: 1em;
ul.birthdays_year {
margin: 0;
list-style-type: none;
@@ -136,7 +135,8 @@
}
ul {
margin: .5em 0 0 1em;
margin: 0;
margin-left: 1em;
list-style-type: square;
list-style-position: inside;
font-weight: normal;
@@ -150,13 +150,9 @@
/* EVENTS TODAY AND NEXT FEW DAYS */
.news_events_group {
box-shadow: $shadow-color 1px 1px 1px;
margin-left: 0;
margin-left: 1em;
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" href="{{ url("com:news_new") }}">
<a class="btn btn-blue margin-bottom" href="{{ url("com:news_new") }}">
<i class="fa fa-plus"></i>
{% trans %}Create news{% endtrans %}
</a>
+1 -2
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, SithFile, User
from core.models import Group, Notification, User
@pytest.mark.django_db
@@ -18,7 +18,6 @@ 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])
+2 -1
View File
@@ -622,7 +622,8 @@ class Command(BaseCommand):
)
pict.file.name = p.name
pict.full_clean()
pict.generate_thumbnails(save=True)
pict.generate_thumbnails()
pict.save()
img_skia = Picture.objects.get(name="skia.jpg")
img_sli = Picture.objects.get(name="sli.jpg")
@@ -1,47 +0,0 @@
# 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),
]
+2 -3
View File
@@ -853,7 +853,7 @@ class SithFile(models.Model):
User,
related_name="owned_files",
verbose_name=_("owner"),
on_delete=models.PROTECT,
on_delete=models.CASCADE,
)
edit_groups = models.ManyToManyField(
Group, related_name="editable_files", verbose_name=_("edit group"), blank=True
@@ -865,7 +865,6 @@ 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,
@@ -873,7 +872,7 @@ class SithFile(models.Model):
verbose_name=_("owner"),
null=True,
blank=True,
on_delete=models.SET_NULL,
on_delete=models.CASCADE,
)
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,18 +47,9 @@ 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) {
@@ -67,8 +58,6 @@ 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: 1.5em 3%;
padding: 1em 1%;
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|string|tojson }}, text: {{ message|string|tojson }} },
{ tag: '{{ message.tags }}', text: '{{ message }}' },
{%- endif -%}
{%- endfor -%}
]
}'
}"
@quick-notification-add="(e) => messages.push(e?.detail)"
@quick-notification-delete="messages = []">
<template x-for="(message, index) in messages">
+1 -2
View File
@@ -33,8 +33,7 @@
<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" x-transition x-cloak>
<fieldset class="form-group" x-show="!isViewable">
{{ 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 }}"></link-once>
<link-once rel="stylesheet" type="text/css" href="{{ css }}" defer></link-once>
{% endfor %}
<{{ component }} name="{{ widget.name }}" {% include "django/forms/widgets/attrs.html" %}>
+1 -2
View File
@@ -21,7 +21,7 @@ from core.baker_recipes import (
subscriber_user,
very_old_subscriber_user,
)
from core.models import AnonymousUser, Group, SithFile, User
from core.models import AnonymousUser, Group, User
from core.views import UserTabsMixin
from counter.baker_recipes import sale_recipe
from counter.models import Counter, Customer, Permanency, Refilling, Selling
@@ -34,7 +34,6 @@ 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,6 +25,7 @@ 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] = (
@@ -177,6 +178,22 @@ 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 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]]
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]}
@@ -28,8 +28,11 @@ document.addEventListener("alpine:init", () => {
this.basket = [];
}
}
document
.getElementById("id_form-TOTAL_FORMS")
// 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")
.setAttribute(":value", "basket.length");
},
+163
View File
@@ -0,0 +1,163 @@
#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
@@ -1,162 +0,0 @@
#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;
}
}
}
+1 -9
View File
@@ -8,14 +8,6 @@
{% 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 #}
@@ -23,7 +15,7 @@
{% endblock %}
{% block additional_css %}
<link rel="stylesheet" href="{{ static("eboutic/css/eboutic.scss") }}">
<link rel="stylesheet" href="{{ static("eboutic/css/eboutic.css") }}">
<link rel="stylesheet" href="{{ static("core/components/card.scss") }}">
{% endblock %}
+108 -102
View File
@@ -6,7 +6,7 @@
msgid ""
msgstr ""
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-05-10 20:27+0200\n"
"POT-Creation-Date: 2026-03-23 22:21+0100\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,22 +181,6 @@ 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"
@@ -259,6 +243,10 @@ 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
@@ -313,22 +301,37 @@ msgstr "Cet email est déjà abonné à cette mailing"
msgid "Unregistered user"
msgstr "Utilisateur non enregistré"
#: 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 "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 "Filters"
msgstr "Filtres"
#: 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
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/user_tools.jinja
msgid "New club"
@@ -430,7 +433,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
#: rootplace/templates/rootplace/logs.jinja sas/forms.py
#: trombi/templates/trombi/user_profile.jinja
msgid "Date"
msgstr "Date"
@@ -578,8 +581,7 @@ msgstr ""
#: counter/templates/counter/invoices_call.jinja
#: counter/templates/counter/product_form.jinja
#: forum/templates/forum/reply.jinja
#: subscription/templates/subscription/fragments/creation_form_existing_user.jinja
#: subscription/templates/subscription/fragments/creation_form_new_user.jinja
#: subscription/templates/subscription/fragments/creation_form.jinja
#: trombi/templates/trombi/comment.jinja
#: trombi/templates/trombi/edit_profile.jinja
#: trombi/templates/trombi/user_tools.jinja
@@ -1690,10 +1692,6 @@ 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é"
@@ -1865,6 +1863,11 @@ 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"
@@ -2889,8 +2892,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)"
@@ -3192,6 +3195,10 @@ 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"
@@ -3690,10 +3697,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"
@@ -3755,8 +3762,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 ""
@@ -3821,14 +3828,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 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
msgid "Remove this action"
msgstr "Retirer cette action"
#: counter/templates/counter/product_form.jinja
#, python-format
msgid "Edit product %(name)s"
@@ -4198,47 +4205,6 @@ 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"
@@ -5338,9 +5304,7 @@ msgid "Amicale/DOCEO member"
msgstr "Membre de l'Amicale/DOCEO"
#: sith/settings.py
#, fuzzy
#| msgid "UT network member"
msgid "UT network member (excluding UTC)"
msgid "UT network member"
msgstr "Cotisant du réseau UT"
#: sith/settings.py
@@ -5351,10 +5315,26 @@ 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%)"
@@ -5375,6 +5355,10 @@ 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"
@@ -5497,7 +5481,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/fragments/creation_form_existing_user.jinja
#: subscription/templates/subscription/forms/create_existing_user.jinja
msgid ""
"If the subscription is done using the AE account, you must also click it on "
"the AE counter."
@@ -5647,6 +5631,10 @@ 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"
@@ -5889,20 +5877,38 @@ 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"
#~ msgid "past member"
#~ msgstr "ancien membre"
#: eboutic/templates/eboutic/eboutic_main.jinja
msgid "Eurockéennes 2025 partnership"
msgstr "Partenariat Eurockéennes 2025"
#~ msgid "One semester Welcome Week"
#~ msgstr "Un semestre Welcome Week"
#: 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 "Eurok's volunteer"
#~ msgstr "Bénévole Eurockéennes"
#: eboutic/templates/eboutic/eboutic_main.jinja
msgid "Privacy policy"
msgstr "Politique de confidentialité"
#~ msgid "Six weeks for free"
#~ msgstr "6 semaines gratuites"
#: 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 "GA staff member"
#~ msgstr "Membre staff GA"
#~ msgid "One year for free(CA offer)"
#~ msgstr "Une année offerte (Offre CA)"
#: 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."
+1 -2
View File
@@ -7,14 +7,13 @@ from model_bakery import baker
from com.models import News
from core.baker_recipes import subscriber_user
from core.models import SithFile, User
from core.models import 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,6 +43,7 @@
"@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",
@@ -2382,6 +2383,52 @@
"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",
@@ -3331,6 +3378,13 @@
"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",
@@ -4021,6 +4075,16 @@
"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,6 +28,7 @@
"@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
@@ -39,7 +39,7 @@ dependencies = [
"jinja2<4.0.0,>=3.1.6",
"django-countries>=8.2.0,<9.0.0",
"dict2xml>=1.7.8,<2.0.0",
"Sphinx<6,>=5",
"Sphinx>=5.3.0,<6",
"tomli>=2.4.1,<3.0.0",
"django-honeypot>=1.3.0,<2",
"pydantic-extra-types>=2.11.1,<3.0.0",
+2 -14
View File
@@ -126,8 +126,9 @@ class PicturesController(ControllerBase):
if self_moderate:
new.moderator = user
try:
new.generate_thumbnails()
new.full_clean()
new.generate_thumbnails(save=True)
new.save()
except ValidationError as e:
return self.create_response({"detail": dict(e)}, status_code=409)
@@ -176,19 +177,6 @@ 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],
+2 -13
View File
@@ -1,24 +1,13 @@
from django.conf import settings
from model_bakery import seq
from model_bakery.recipe import Recipe, foreign_key
from model_bakery.recipe import Recipe
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 "),
)
from sas.models import Picture
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.
+68 -57
View File
@@ -15,6 +15,8 @@
from __future__ import annotations
import contextlib
from io import BytesIO
from pathlib import Path
from typing import ClassVar, Self
@@ -28,7 +30,7 @@ from django.utils.translation import gettext_lazy as _
from PIL import Image
from core.models import Notification, SithFile, User
from core.utils import resize_image
from core.utils import exif_auto_rotate, resize_image
class SasFile(SithFile):
@@ -90,75 +92,88 @@ 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},
query={"date": int(self.updated_at.timestamp())},
)
return reverse("sas:download", kwargs={"picture_id": self.id})
def get_download_compressed_url(self):
return reverse(
"sas:download_compressed",
kwargs={"picture_id": self.id},
query={"date": int(self.updated_at.timestamp())},
)
return reverse("sas:download_compressed", kwargs={"picture_id": self.id})
def get_download_thumb_url(self):
return reverse(
"sas:download_thumb",
kwargs={"picture_id": self.id},
query={"date": int(self.updated_at.timestamp())},
)
return reverse("sas:download_thumb", kwargs={"picture_id": self.id})
def get_absolute_url(self):
return reverse("sas:picture", kwargs={"picture_id": self.id})
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
]
def generate_thumbnails(self):
im = Image.open(BytesIO(self.file.read()))
with contextlib.suppress(Exception):
im = exif_auto_rotate(im)
# 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 a user)
# for less frequent cases (like downloading the pictures of an user)
extension = self.mime_type.split("/")[-1]
# the HD version of the image doesn't need to be optimized, because :
# - it isn't frequently queried
# - optimizing large images takes a lot of time, which greatly hinders the UX
# - optimizing large images takes a lot 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"))
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)
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
def rotate(self, degree: int | float):
"""Rotate this picture and update its thumbnails accordingly.
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,
)
Args:
degree: the rotation angle, in degree, counter-clockwise
"""
img = Image.open(self.file).rotate(degree)
self.generate_thumbnails(img=img, save=True)
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()
class AlbumQuerySet(models.QuerySet):
@@ -224,11 +239,7 @@ 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},
query={"date": int(self.updated_at.timestamp())},
)
return reverse("sas:album_preview", kwargs={"album_id": self.id})
def generate_thumbnail(self):
p = self.children_pictures.order_by("?").first()
+1 -9
View File
@@ -70,15 +70,7 @@ class PictureFilterSchema(FilterSchema):
class PictureSchema(ModelSchema):
class Meta:
model = Picture
fields = [
"id",
"name",
"date",
"updated_at",
"size",
"is_moderated",
"asked_for_removal",
]
fields = ["id", "name", "date", "size", "is_moderated", "asked_for_removal"]
owner: UserProfileSchema
sas_url: str
+3 -18
View File
@@ -1,4 +1,4 @@
import { paginated } from "#core:utils/api";
import { paginated } from "#core:utils/api.ts";
import {
type PictureSchema,
type PicturesFetchPicturesData,
@@ -35,23 +35,8 @@ document.addEventListener("alpine:init", () => {
// biome-ignore lint/style/useNamingConvention: from python api
query: { users_identified: [config.userId] },
} as PicturesFetchPicturesData);
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);
});
}
localStorage.setItem(localStorageInvalidationKey, config.nbPictures.toString());
localStorage.setItem(localStorageKey, JSON.stringify(pictures));
return pictures;
},
+11 -38
View File
@@ -1,7 +1,7 @@
import type TomSelect from "tom-select";
import type { UserAjaxSelect } from "#core:core/components/ajax-select-index";
import { paginated } from "#core:utils/api";
import { History } from "#core:utils/history";
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 IdentifiedUserSchema,
type ModerationRequestSchema,
@@ -14,7 +14,6 @@ import {
picturesFetchPictures,
picturesIdentifyUsers,
picturesModeratePicture,
picturesRotatePicture,
type UserProfileSchema,
usersidentifiedDeleteRelation,
} from "#openapi";
@@ -29,32 +28,18 @@ class PictureWithIdentifications {
identificationsLoading = false;
moderationLoading = false;
id: number;
compressedUrl: string = "";
thumbUrl: string = "";
fullSizeUrl: string = "";
// biome-ignore lint/style/useNamingConvention: api is in snake_case
compressed_url: 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
@@ -97,25 +82,12 @@ 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.compressedUrl;
img.src = this.compressed_url;
if (!img.complete) {
this.imageLoading = true;
img.addEventListener("load", () => {
@@ -168,8 +140,7 @@ document.addEventListener("alpine:init", () => {
// biome-ignore lint/style/useNamingConvention: api is in snake_case
full_size_url: "",
owner: "",
// biome-ignore lint/style/useNamingConvention: api is in snake_case
created_at: new Date(),
date: new Date(),
identifications: [] as IdentifiedUserSchema[],
},
/**
@@ -320,8 +291,10 @@ document.addEventListener("alpine:init", () => {
async submitIdentification(): Promise<void> {
const widget: TomSelect = this.selector.widget;
await picturesIdentifyUsers({
// biome-ignore lint/style/useNamingConvention: api is in snake_case
path: { picture_id: this.currentPicture.id },
path: {
// biome-ignore lint/style/useNamingConvention: api is in snake_case
picture_id: this.currentPicture.id,
},
body: widget.items.map((i: string) => Number.parseInt(i, 10)),
});
// refresh the identified users list
+25 -22
View File
@@ -235,34 +235,37 @@
>.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;
&:hover {
background-color: #aaa;
>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;
}
}
}
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;
}
}
}
}
+12 -27
View File
@@ -1,9 +1,9 @@
{% extends "core/base.jinja" %}
{%- block additional_css -%}
<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') }}">
<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') }}">
{%- endblock -%}
{%- block additional_js -%}
@@ -84,7 +84,7 @@
<div class="main">
<div class="photo" :aria-busy="currentPicture.imageLoading">
<img
:src="currentPicture.compressedUrl"
:src="currentPicture.compressed_url"
:alt="currentPicture.name"
id="main-picture"
x-ref="mainPicture"
@@ -100,7 +100,7 @@
<span
x-text="Intl.DateTimeFormat(
'{{ LANGUAGE_CODE }}', {dateStyle: 'long'}
).format(Date.parse(currentPicture.date))"
).format(new Date(currentPicture.date))"
>
</span>
</div>
@@ -115,38 +115,23 @@
<h5>{% trans %}Tools{% endtrans %}</h5>
<div>
<div>
<a class="text" :href="currentPicture.fullSizeUrl">
<a class="text" :href="currentPicture.full_size_url">
{% trans %}HD version{% endtrans %}
</a>
<a class="text danger " :href="currentPicture.report_url">
{% trans %}Ask for removal{% endtrans %}
</a>
</div>
<div
class="buttons"
x-show="{{ user.has_perm("sas.change_sasfile")|tojson }} || currentPicture.owner.id === {{ user.id }}"
>
<div class="buttons">
<a
class="btn btn-no-text"
:href="currentPicture.edit_url"
:disabled="currentPicture.imageLoading"
x-show="{{ user.has_perm("sas.change_sasfile")|tojson }} || currentPicture.owner.id === {{ user.id }}"
>
<i class="fa-regular fa-pen-to-square edit-action"></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>
<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>
</div>
</div>
</div>
@@ -161,7 +146,7 @@
@keyup.left.window="currentPicture = previousPicture"
@click="currentPicture = previousPicture"
>
<img :src="previousPicture.thumbUrl" alt="{% trans %}Previous picture{% endtrans %}"/>
<img :src="previousPicture.thumb_url" alt="{% trans %}Previous picture{% endtrans %}"/>
<div class="overlay">←</div>
</div>
</template>
@@ -172,7 +157,7 @@
@keyup.right.window="currentPicture = nextPicture"
@click="currentPicture = nextPicture"
>
<img :src="nextPicture.thumbUrl" alt="{% trans %}Previous picture{% endtrans %}"/>
<img :src="nextPicture.thumb_url" alt="{% trans %}Previous picture{% endtrans %}"/>
<div class="overlay">→</div>
</div>
</template>
-38
View File
@@ -1,11 +1,6 @@
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
@@ -72,36 +67,3 @@ 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)
+1 -70
View File
@@ -12,23 +12,19 @@
# OR WITHIN THE LOCAL FILE "LICENSE"
#
#
from typing import Callable, Literal
from unittest.mock import patch
from typing import Callable
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
@@ -166,71 +162,6 @@ 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,6 +97,14 @@ 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)
+18 -4
View File
@@ -523,14 +523,22 @@ 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 (hors UTC)": {
"name": _("UT network member (excluding UTC)"),
"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"),
"price": 0,
"duration": 1,
},
"crous": {"name": _("CROUS member"), "price": 0, "duration": 2},
"sbarro/esta": {"name": _("Sbarro/ESTA member"), "price": 15, "duration": 2},
"benevoles-euroks": {"name": _("Eurok's volunteer"), "price": 5, "duration": 0.1},
"six-semaines-essai": {
"name": _("Six weeks for free"),
"price": 0,
"duration": 0.23,
},
"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%)"),
@@ -557,6 +565,12 @@ 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....
}
+2 -6
View File
@@ -79,6 +79,7 @@ class SubscriptionNewUserForm(SubscriptionForm):
"""
allowed_payment_methods = ["CARD", "CASH"]
template_name = "subscription/forms/create_new_user.jinja"
__user_fields = forms.fields_for_model(
User,
@@ -120,12 +121,6 @@ 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",
@@ -158,6 +153,7 @@ 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,42 +1,38 @@
import { userFetchUser } from "#openapi";
Alpine.data("existing_user_subscription_form", () => ({
loading: false,
selectedUser: "",
profileFragment: "",
dateOfBirth: "",
dateOfBirthHidden: true,
document.addEventListener("alpine:init", () => {
Alpine.data("existing_user_subscription_form", () => ({
loading: false,
profileFragment: "" as string,
init() {
this.$watch("selectedUser", async () => {
await this.loadProfile(Number.parseInt(this.selectedUser, 10));
});
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));
},
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;
},
}));
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;
},
}));
});
@@ -5,8 +5,7 @@
margin-top: 0;
}
fieldset p:first-of-type,
&>p:first-of-type {
fieldset p:first-of-type, & > p:first-of-type {
margin-top: 0;
}
@@ -25,7 +24,7 @@
fieldset {
flex: 0 1 auto;
.form-group:has(input[hidden]) {
p:has(input[hidden]) {
// when the input is hidden, hide the whole label+input+help text group
display: none;
}
@@ -0,0 +1,28 @@
{% 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>
@@ -0,0 +1 @@
{{ form.as_p }}
@@ -1,5 +1,5 @@
<form
hx-post="{{ url("subscription:fragment-new-user") }}"
hx-post="{{ post_url }}"
hx-target="this"
hx-disabled-elt="find input[type='submit']"
hx-swap="outerHTML"
@@ -1,41 +0,0 @@
<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>
@@ -18,14 +18,22 @@
<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>
{{ existing_user_fragment }}
{{ form_fragment(existing_user_form, existing_user_post_url) }}
</ui-tab>
<ui-tab title="{% trans %}New member{% endtrans %}">
{{ new_user_fragment }}
{{ form_fragment(new_user_form, new_user_post_url) }}
</ui-tab>
</ui-tab-group>
{% endblock %}
+10 -7
View File
@@ -12,6 +12,7 @@ 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
@@ -25,7 +26,9 @@ 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]):
def test_form_existing_user_valid(
user_factory: Callable[[], User], settings: SettingsWrapper
):
"""Test `SubscriptionExistingUserForm`"""
user = user_factory()
user.date_of_birth = date(year=1967, month=3, day=14)
@@ -45,7 +48,7 @@ def test_form_existing_user_valid(user_factory: Callable[[], User]):
@pytest.mark.django_db
def test_form_existing_user_with_birthdate():
def test_form_existing_user_with_birthdate(settings: SettingsWrapper):
"""Test `SubscriptionExistingUserForm`"""
user = baker.make(User, date_of_birth=None)
data = {
@@ -67,7 +70,7 @@ def test_form_existing_user_with_birthdate():
@pytest.mark.django_db
def test_form_existing_user_invalid():
def test_form_existing_user_invalid(settings: SettingsWrapper):
"""Test `SubscriptionExistingUserForm`, with users that shouldn't subscribe."""
user = subscriber_user.make()
# make sure the current subscription will end in a long time
@@ -88,7 +91,7 @@ def test_form_existing_user_invalid():
@pytest.mark.django_db
def test_form_new_user():
def test_form_new_user(settings: SettingsWrapper):
data = {
"first_name": "John",
"last_name": "Doe",
@@ -118,7 +121,7 @@ def test_form_new_user():
"subscription_type",
["un-semestre", "deux-semestres", "cursus-tronc-commun", "cursus-branche"],
)
def test_form_set_new_user_as_student(subscription_type):
def test_form_set_new_user_as_student(settings: SettingsWrapper, subscription_type):
"""Test that new users have the student role by default."""
data = {
"first_name": "John",
@@ -162,7 +165,7 @@ def test_page_access_with_get_data(client: Client):
@pytest.mark.django_db
def test_submit_form_existing_user(client: Client):
def test_submit_form_existing_user(client: Client, settings: SettingsWrapper):
client.force_login(
baker.make(
User,
@@ -193,7 +196,7 @@ def test_submit_form_existing_user(client: Client):
@pytest.mark.django_db
def test_submit_form_new_user(client: Client):
def test_submit_form_new_user(client: Client, settings: SettingsWrapper):
client.force_login(
baker.make(
User,
+19 -14
View File
@@ -23,7 +23,6 @@ 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,
@@ -33,9 +32,24 @@ from subscription.forms import (
from subscription.models import Subscription
class CreateSubscriptionFragment(PermissionRequiredMixin, FragmentMixin, CreateView):
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"
permission_required = "subscription.add_subscription"
object = None
def get_success_url(self):
return reverse(
@@ -47,14 +61,14 @@ class CreateSubscriptionExistingUserFragment(CreateSubscriptionFragment):
"""Create a subscription for a user who already exists."""
form_class = SubscriptionExistingUserForm
template_name = "subscription/fragments/creation_form_existing_user.jinja"
extra_context = {"post_url": reverse_lazy("subscription:fragment-existing-user")}
class CreateSubscriptionNewUserFragment(CreateSubscriptionFragment):
"""Create a subscription for a user who doesn't exist yet."""
form_class = SubscriptionNewUserForm
template_name = "subscription/fragments/creation_form_new_user.jinja"
extra_context = {"post_url": reverse_lazy("subscription:fragment-new-user")}
def form_valid(self, form):
res = super().form_valid(form)
@@ -69,15 +83,6 @@ 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"
+10 -3
View File
@@ -1,4 +1,5 @@
import { parse, resolve } from "node:path";
import inject from "@rollup/plugin-inject";
import { glob } from "glob";
import { visualizer } from "rollup-plugin-visualizer";
import {
@@ -80,8 +81,14 @@ export default defineConfig((config: UserConfig) => {
resolve: {
alias: getAliases(),
},
// 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],
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,
],
} satisfies UserConfig;
});