mirror of
https://github.com/ae-utbm/sith.git
synced 2026-05-13 20:48:06 +00:00
Compare commits
38 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 00388b2d78 | |||
| 790a1e15b1 | |||
| 387e5fe0a3 | |||
| c371a5cf7a | |||
| 07eae232d0 | |||
| e4fa9c3c4a | |||
|
5551fdc953
|
|||
|
2d86ba67f4
|
|||
| deb83ec08f | |||
| 94cda48508 | |||
| 7bd2f1da96 | |||
| e2d2eb7470 | |||
| a18f316088 | |||
| fee1bbc5a5 | |||
|
cb1a330983
|
|||
| 1cfeeefa56 | |||
| 0308d1c887 | |||
| 9abd16c8f6 | |||
|
0405ef424d
|
|||
| b79b7cbcf5 | |||
| 2c259de22c | |||
| 38f2b6aa7b | |||
| 37345f1bc4 | |||
| 151d17aca1 | |||
| d123e5e35b | |||
| 00f7afb937 | |||
| 2dbf4cff05 | |||
| f88c061b02 | |||
| 4bd248f827 | |||
| 2aa6eed2fc | |||
| 7fec05820c | |||
| 381f1ac829 | |||
| 22e6c09c36 | |||
| 399a3813f0 | |||
| 441a016025 | |||
| 060dde78e7 | |||
| f19b3056ef | |||
| 5c17337595 |
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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" %}>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -431,7 +431,7 @@ class PriceQuerySet(models.QuerySet):
|
||||
),
|
||||
product__archived=False,
|
||||
product__limit_age__lte=age,
|
||||
)
|
||||
).distinct()
|
||||
|
||||
|
||||
class Price(models.Model):
|
||||
|
||||
@@ -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");
|
||||
},
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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),
|
||||
|
||||
Generated
-64
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
+14
-2
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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):
|
||||
|
||||
@@ -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
@@ -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....
|
||||
}
|
||||
|
||||
|
||||
@@ -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
-1
@@ -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 %}
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -2424,11 +2424,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "urllib3"
|
||||
version = "2.6.3"
|
||||
version = "2.7.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
+3
-10
@@ -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;
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user