Merge pull request #849 from ae-utbm/taiste

New 3DSv2 fields and Bugfixes
This commit is contained in:
thomas girod 2024-09-30 11:33:32 +02:00 committed by GitHub
commit 3548deebf6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
30 changed files with 884 additions and 370 deletions

View File

@ -2,6 +2,7 @@ from typing import Annotated
import annotated_types import annotated_types
from django.conf import settings from django.conf import settings
from django.db.models import F
from django.http import HttpResponse from django.http import HttpResponse
from ninja import Query from ninja import Query
from ninja_extra import ControllerBase, api_controller, paginate, route from ninja_extra import ControllerBase, api_controller, paginate, route
@ -49,10 +50,16 @@ class UserController(ControllerBase):
def fetch_profiles(self, pks: Query[set[int]]): def fetch_profiles(self, pks: Query[set[int]]):
return User.objects.filter(pk__in=pks) return User.objects.filter(pk__in=pks)
@route.get("/search", response=PaginatedResponseSchema[UserProfileSchema]) @route.get(
"/search",
response=PaginatedResponseSchema[UserProfileSchema],
url_name="search_users",
)
@paginate(PageNumberPaginationExtra, page_size=20) @paginate(PageNumberPaginationExtra, page_size=20)
def search_users(self, filters: Query[UserFilterSchema]): def search_users(self, filters: Query[UserFilterSchema]):
return filters.filter(User.objects.all()) return filters.filter(
User.objects.order_by(F("last_login").desc(nulls_last=True))
)
DepthValue = Annotated[int, annotated_types.Ge(0), annotated_types.Le(10)] DepthValue = Annotated[int, annotated_types.Ge(0), annotated_types.Le(10)]

View File

@ -1,9 +1,11 @@
from datetime import timedelta from datetime import timedelta
from django.conf import settings
from django.utils.timezone import now from django.utils.timezone import now
from model_bakery import seq from model_bakery import seq
from model_bakery.recipe import Recipe, related from model_bakery.recipe import Recipe, related
from club.models import Membership
from core.models import User from core.models import User
from subscription.models import Subscription from subscription.models import Subscription
@ -24,9 +26,27 @@ subscriber_user = Recipe(
last_name=seq("user "), last_name=seq("user "),
subscriptions=related(active_subscription), subscriptions=related(active_subscription),
) )
"""A user with an active subscription."""
old_subscriber_user = Recipe( old_subscriber_user = Recipe(
User, User,
first_name="old subscriber", first_name="old subscriber",
last_name=seq("user "), last_name=seq("user "),
subscriptions=related(ended_subscription), subscriptions=related(ended_subscription),
) )
"""A user with an ended subscription."""
ae_board_membership = Recipe(
Membership,
start_date=now() - timedelta(days=30),
club_id=settings.SITH_MAIN_CLUB_ID,
role=settings.SITH_CLUB_ROLES_ID["Board member"],
)
board_user = Recipe(
User,
first_name="AE",
last_name=seq("member "),
memberships=related(ae_board_membership),
)
"""A user which is in the board of the AE."""

View File

@ -1057,20 +1057,38 @@ class SithFile(models.Model):
if self.is_file and (self.file is None or self.file == ""): if self.is_file and (self.file is None or self.file == ""):
raise ValidationError(_("You must provide a file")) raise ValidationError(_("You must provide a file"))
def apply_rights_recursively(self, *, only_folders=False): def apply_rights_recursively(self, *, only_folders: bool = False) -> None:
children = self.children.all() """Apply the rights of this file to all children recursively.
if only_folders:
children = children.filter(is_folder=True) Args:
for c in children: only_folders: If True, only apply the rights to SithFiles that are folders.
c.copy_rights() """
c.apply_rights_recursively(only_folders=only_folders) file_ids = []
explored_ids = [self.id]
while len(explored_ids) > 0: # find all children recursively
file_ids.extend(explored_ids)
next_level = SithFile.objects.filter(parent_id__in=explored_ids)
if only_folders:
next_level = next_level.filter(is_folder=True)
explored_ids = list(next_level.values_list("id", flat=True))
for through in (SithFile.view_groups.through, SithFile.edit_groups.through):
# force evaluation. Without this, the iterator yields nothing
groups = list(
through.objects.filter(sithfile_id=self.id).values_list(
"group_id", flat=True
)
)
# delete previous rights
through.objects.filter(sithfile_id__in=file_ids).delete()
through.objects.bulk_create( # create new rights
[through(sithfile_id=f, group_id=g) for f in file_ids for g in groups]
)
def copy_rights(self): def copy_rights(self):
"""Copy, if possible, the rights of the parent folder.""" """Copy, if possible, the rights of the parent folder."""
if self.parent is not None: if self.parent is not None:
self.edit_groups.set(self.parent.edit_groups.all()) self.edit_groups.set(self.parent.edit_groups.all())
self.view_groups.set(self.parent.view_groups.all()) self.view_groups.set(self.parent.view_groups.all())
self.save()
def move_to(self, parent): def move_to(self, parent):
"""Move a file to a new parent. """Move a file to a new parent.

View File

@ -66,7 +66,6 @@ class UserFilterSchema(FilterSchema):
SearchQuerySet() SearchQuerySet()
.models(User) .models(User)
.autocomplete(auto=slugify(value).replace("-", " ")) .autocomplete(auto=slugify(value).replace("-", " "))
.order_by("-last_update")
.values_list("pk", flat=True) .values_list("pk", flat=True)
) )
) )

View File

@ -33,6 +33,9 @@ class UserIndex(indexes.SearchIndex, indexes.Indexable):
text = indexes.CharField(document=True, use_template=True) text = indexes.CharField(document=True, use_template=True)
auto = indexes.EdgeNgramField(use_template=True) auto = indexes.EdgeNgramField(use_template=True)
last_update = indexes.DateTimeField(model_attr="last_update") last_update = indexes.DateTimeField(model_attr="last_update")
last_login = indexes.DateTimeField(
model_attr="last_login", default="1970-01-01T00:00:00Z"
)
def get_model(self): def get_model(self):
return User return User

View File

@ -108,7 +108,8 @@ function update_query_string(key, value, action = History.REPLACE, url = null) {
return url; return url;
} }
// TODO : If one day a test workflow is made for JS in this project
// please test this function. A all cost.
/** /**
* Given a paginated endpoint, fetch all the items of this endpoint, * Given a paginated endpoint, fetch all the items of this endpoint,
* performing multiple API calls if necessary. * performing multiple API calls if necessary.
@ -135,7 +136,7 @@ async function fetch_paginated(url) {
fetch(paginated_url).then(res => res.json().then(json => json.results)) fetch(paginated_url).then(res => res.json().then(json => json.results))
); );
} }
results.push(...await Promise.all(promises)) results.push(...(await Promise.all(promises)).flat())
} }
return results; return results;
} }

View File

@ -306,6 +306,12 @@ a:not(.button) {
align-items: center; align-items: center;
text-align: justify; text-align: justify;
&.alert-yellow {
background-color: rgb(255, 255, 240);
color: rgb(99, 87, 6);
border: rgb(192, 180, 16) 1px solid;
}
&.alert-green { &.alert-green {
background-color: rgb(245, 255, 245); background-color: rgb(245, 255, 245);
color: rgb(3, 84, 63); color: rgb(3, 84, 63);

View File

@ -77,11 +77,11 @@ function create_graph(container, data, active_user_id) {
fit: true, fit: true,
klay: { klay: {
addUnnecessaryBendpoints: true, addUnnecessaryBendpoints: true,
direction: 'DOWN', direction: "DOWN",
nodePlacement: 'INTERACTIVE', nodePlacement: "INTERACTIVE",
layoutHierarchy: true layoutHierarchy: true,
} },
} },
}); });
let active_user = cy let active_user = cy
.getElementById(active_user_id) .getElementById(active_user_id)
@ -178,7 +178,9 @@ document.addEventListener("alpine:init", () => {
typeof depth_min === "undefined" || typeof depth_min === "undefined" ||
typeof depth_max === "undefined" typeof depth_max === "undefined"
) { ) {
console.error("Some constants are not set before using the family_graph script, please look at the documentation"); console.error(
"Some constants are not set before using the family_graph script, please look at the documentation",
);
return; return;
} }
@ -194,7 +196,7 @@ document.addEventListener("alpine:init", () => {
loading: false, loading: false,
godfathers_depth: get_initial_depth("godfathers_depth"), godfathers_depth: get_initial_depth("godfathers_depth"),
godchildren_depth: get_initial_depth("godchildren_depth"), godchildren_depth: get_initial_depth("godchildren_depth"),
reverse: initialUrlParams.get("reverse")?.toLowerCase?.() === 'true', reverse: initialUrlParams.get("reverse")?.toLowerCase?.() === "true",
graph: undefined, graph: undefined,
graph_data: {}, graph_data: {},
@ -227,7 +229,11 @@ document.addEventListener("alpine:init", () => {
async screenshot() { async screenshot() {
const link = document.createElement("a"); const link = document.createElement("a");
link.href = this.graph.jpg(); link.href = this.graph.jpg();
link.download = gettext("family_tree.%(extension)s", "jpg"); link.download = interpolate(
gettext("family_tree.%(extension)s"),
{ extension: "jpg" },
true,
);
document.body.appendChild(link); document.body.appendChild(link);
link.click(); link.click();
document.body.removeChild(link); document.body.removeChild(link);

View File

@ -0,0 +1,110 @@
function alpine_webcam_builder(
default_picture,
delete_url,
can_delete_picture,
) {
return () => ({
can_edit_picture: false,
loading: false,
is_camera_enabled: false,
is_camera_error: false,
picture: null,
video: null,
picture_form: null,
init() {
this.video = this.$refs.video;
this.picture_form = this.$refs.form.getElementsByTagName("input");
if (this.picture_form.length > 0) {
this.picture_form = this.picture_form[0];
this.can_edit_picture = true;
// Link the displayed element to the form input
this.picture_form.onchange = (event) => {
let files = event.srcElement.files;
if (files.length > 0) {
this.picture = (window.URL || window.webkitURL).createObjectURL(
event.srcElement.files[0],
);
} else {
this.picture = null;
}
};
}
},
get_picture() {
return this.picture || default_picture;
},
delete_picture() {
// Only remove currently displayed picture
if (!!this.picture) {
let list = new DataTransfer();
this.picture_form.files = list.files;
this.picture_form.dispatchEvent(new Event("change"));
return;
}
if (!can_delete_picture) {
return;
}
// Remove user picture if correct rights are available
window.open(delete_url, "_self");
},
enable_camera() {
this.picture = null;
this.loading = true;
this.is_camera_error = false;
navigator.mediaDevices
.getUserMedia({ video: true, audio: false })
.then((stream) => {
this.loading = false;
this.is_camera_enabled = true;
this.video.srcObject = stream;
this.video.play();
})
.catch((err) => {
this.is_camera_error = true;
this.loading = false;
});
},
take_picture() {
let canvas = document.createElement("canvas");
const context = canvas.getContext("2d");
/* Create the image */
let settings = this.video.srcObject.getTracks()[0].getSettings();
canvas.width = settings.width;
canvas.height = settings.height;
context.drawImage(this.video, 0, 0, canvas.width, canvas.height);
/* Stop camera */
this.video.pause();
this.video.srcObject.getTracks().forEach((track) => {
if (track.readyState === "live") {
track.stop();
}
});
canvas.toBlob((blob) => {
const filename = interpolate(gettext("captured.%s"), ["webp"]);
let file = new File([blob], filename, {
type: "image/webp",
});
let list = new DataTransfer();
list.items.add(file);
this.picture_form.files = list.files;
// No change event is triggered, we trigger it manually #}
this.picture_form.dispatchEvent(new Event("change"));
}, "image/webp");
canvas.remove();
this.is_camera_enabled = false;
},
});
}

View File

@ -8,7 +8,12 @@
<link rel="stylesheet" href="{{ scss('user/user_edit.scss') }}"> <link rel="stylesheet" href="{{ scss('user/user_edit.scss') }}">
{%- endblock -%} {%- endblock -%}
{% block additional_js %}
<script defer src="{{ static("user/js/user_edit.js") }}"></script>
{% endblock %}
{% macro profile_picture(field_name) %} {% macro profile_picture(field_name) %}
{% set this_picture = form.instance[field_name] %}
<div class="profile-picture" x-data="camera_{{ field_name }}" > <div class="profile-picture" x-data="camera_{{ field_name }}" >
<div class="profile-picture-display" :aria-busy="loading" :class="{ 'camera-error': is_camera_error }"> <div class="profile-picture-display" :aria-busy="loading" :class="{ 'camera-error': is_camera_error }">
<img <img
@ -50,8 +55,8 @@
<div> <div>
{{ form[field_name] }} {{ form[field_name] }}
<button class="btn btn-red" @click.prevent="delete_picture()" <button class="btn btn-red" @click.prevent="delete_picture()"
{%- if not (user.is_root and form.instance[field_name]) -%} {%- if not (this_picture and this_picture.is_owned_by(user)) -%}
:disabled="picture == null" :disabled="!picture"
{%- endif -%} {%- endif -%}
x-cloak x-cloak
> >
@ -68,128 +73,25 @@
</div> </div>
</div> </div>
<script> <script>
{%- if this_picture -%}
{% set default_picture = this_picture.get_download_url()|tojson %}
{% set delete_url = (
url('core:file_delete', file_id=this_picture.id, popup='')
+"?next=" + profile.get_absolute_url()
)|tojson %}
{%- else -%}
{% set default_picture = static('core/img/unknown.jpg')|tojson %}
{% set delete_url = "null" %}
{%- endif -%}
document.addEventListener("alpine:init", () => { document.addEventListener("alpine:init", () => {
Alpine.data("camera_{{ field_name }}", () => ({ Alpine.data(
can_edit_picture: false, "camera_{{ field_name }}",
alpine_webcam_builder(
loading: false, {{ default_picture }},
is_camera_enabled: false, {{ delete_url }},
is_camera_error: false, {{ (this_picture and this_picture.is_owned_by(user))|tojson }}
picture: null, )
video: null, );
picture_form: null,
init() {
this.video = this.$refs.video;
this.picture_form = this.$refs.form.getElementsByTagName("input");
if (this.picture_form.length > 0){
this.picture_form = this.picture_form[0];
this.can_edit_picture = true;
{# Link the displayed element to the form input #}
this.picture_form.onchange = (event) => {
let files = event.srcElement.files;
if (files.length > 0){
this.picture = (window.URL || window.webkitURL)
.createObjectURL(event.srcElement.files[0]);
} else {
this.picture = null;
}
}
}
},
get_picture() {
if (this.picture != null) {
return this.picture;
}
{%- if form.instance[field_name] -%}
return "{{ form.instance[field_name].get_download_url() }}"
{%- else -%}
return "{{ static('core/img/unknown.jpg') }}"
{%- endif -%}
},
delete_picture() {
{# Only remove currently displayed picture #}
if (this.picture != null){
let list = new DataTransfer();
this.picture_form.files = list.files;
this.picture_form.dispatchEvent(new Event("change"));
return;
}
{# Remove user picture if correct rights are available #}
{%- if user.is_root and form.instance[field_name] -%}
window.open(
'{{ url(
'core:file_delete',
file_id=form.instance[field_name].id,
popup=''
) }}',
'_self');
{%- endif -%}
},
enable_camera() {
this.picture = null;
this.loading = true;
this.is_camera_error = false;
navigator.mediaDevices
.getUserMedia({ video: true, audio: false })
.then((stream) => {
this.loading = false;
this.is_camera_enabled = true;
this.video.srcObject = stream;
this.video.play();
})
.catch((err) => {
this.is_camera_error = true;
this.loading = false;
});
},
take_picture() {
let canvas = document.createElement("canvas")
const context = canvas.getContext("2d");
/* Create the image */
let settings = this.video.srcObject.getTracks()[0].getSettings();
canvas.width = settings.width;
canvas.height = settings.height;
context.drawImage(this.video, 0, 0, canvas.width, canvas.height);
/* Stop camera */
this.video.pause()
this.video.srcObject.getTracks().forEach((track) => {
if (track.readyState === 'live') {
track.stop();
}
});
canvas.toBlob((blob) => {
let file = new File(
[blob],
"{% trans %}captured{% endtrans %}.webp",
{ type: "image/webp" },
);
let list = new DataTransfer();
list.items.add(file);
this.picture_form.files = list.files;
{# No change event is triggered, we trigger it manually #}
this.picture_form.dispatchEvent(new Event("change"));
}, "image/webp");
canvas.remove();
this.is_camera_enabled = false;
},
}));
}); });
</script> </script>
{% endmacro %} {% endmacro %}

View File

@ -356,82 +356,6 @@ class TestUserTools:
assert response.status_code == 200 assert response.status_code == 200
@pytest.mark.django_db
class TestUserPicture:
def test_anonymous_user_unauthorized(self, client):
"""An anonymous user shouldn't have access to an user's photo page."""
response = client.get(
reverse(
"core:user_pictures",
kwargs={"user_id": User.objects.get(username="sli").pk},
)
)
assert response.status_code == 403
@pytest.mark.parametrize(
("username", "status"),
[
("guy", 403),
("root", 200),
("skia", 200),
("sli", 200),
],
)
def test_page_is_working(self, client, username, status):
"""Only user that subscribed (or admins) should be able to see the page."""
# Test for simple user
client.force_login(User.objects.get(username=username))
response = client.get(
reverse(
"core:user_pictures",
kwargs={"user_id": User.objects.get(username="sli").pk},
)
)
assert response.status_code == status
# TODO: many tests on the pages:
# - renaming a page
# - changing a page's parent --> check that page's children's full_name
# - changing the different groups of the page
class TestFileHandling(TestCase):
@classmethod
def setUpTestData(cls):
cls.subscriber = User.objects.get(username="subscriber")
def setUp(self):
self.client.login(username="subscriber", password="plop")
def test_create_folder_home(self):
response = self.client.post(
reverse("core:file_detail", kwargs={"file_id": self.subscriber.home.id}),
{"folder_name": "GUY_folder_test"},
)
assert response.status_code == 302
response = self.client.get(
reverse("core:file_detail", kwargs={"file_id": self.subscriber.home.id})
)
assert response.status_code == 200
assert "GUY_folder_test</a>" in str(response.content)
def test_upload_file_home(self):
with open("/bin/ls", "rb") as f:
response = self.client.post(
reverse(
"core:file_detail", kwargs={"file_id": self.subscriber.home.id}
),
{"file_field": f},
)
assert response.status_code == 302
response = self.client.get(
reverse("core:file_detail", kwargs={"file_id": self.subscriber.home.id})
)
assert response.status_code == 200
assert "ls</a>" in str(response.content)
class TestUserIsInGroup(TestCase): class TestUserIsInGroup(TestCase):
"""Test that the User.is_in_group() and AnonymousUser.is_in_group() """Test that the User.is_in_group() and AnonymousUser.is_in_group()
work as intended. work as intended.

View File

@ -22,7 +22,7 @@ class TestFetchFamilyApi(TestCase):
# <- user5 # <- user5
cls.main_user = baker.make(User) cls.main_user = baker.make(User)
cls.users = baker.make(User, _quantity=17) cls.users = baker.make(User, _quantity=17, _bulk_create=True)
cls.main_user.godfathers.add(*cls.users[0:3]) cls.main_user.godfathers.add(*cls.users[0:3])
cls.main_user.godchildren.add(*cls.users[3:6]) cls.main_user.godchildren.add(*cls.users[3:6])
cls.users[1].godfathers.add(cls.users[6]) cls.users[1].godfathers.add(cls.users[6])

221
core/tests/test_files.py Normal file
View File

@ -0,0 +1,221 @@
from io import BytesIO
from itertools import cycle
from typing import Callable
from uuid import uuid4
import pytest
from django.core.cache import cache
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import Client, TestCase
from django.urls import reverse
from model_bakery import baker
from model_bakery.recipe import Recipe, foreign_key
from PIL import Image
from pytest_django.asserts import assertNumQueries
from core.baker_recipes import board_user, subscriber_user
from core.models import Group, SithFile, User
@pytest.mark.django_db
class TestUserPicture:
def test_anonymous_user_unauthorized(self, client):
"""An anonymous user shouldn't have access to an user's photo page."""
response = client.get(
reverse(
"core:user_pictures",
kwargs={"user_id": User.objects.get(username="sli").pk},
)
)
assert response.status_code == 403
@pytest.mark.parametrize(
("username", "status"),
[
("guy", 403),
("root", 200),
("skia", 200),
("sli", 200),
],
)
def test_page_is_working(self, client, username, status):
"""Only user that subscribed (or admins) should be able to see the page."""
# Test for simple user
client.force_login(User.objects.get(username=username))
response = client.get(
reverse(
"core:user_pictures",
kwargs={"user_id": User.objects.get(username="sli").pk},
)
)
assert response.status_code == status
# TODO: many tests on the pages:
# - renaming a page
# - changing a page's parent --> check that page's children's full_name
# - changing the different groups of the page
class TestFileHandling(TestCase):
@classmethod
def setUpTestData(cls):
cls.subscriber = User.objects.get(username="subscriber")
def setUp(self):
self.client.login(username="subscriber", password="plop")
def test_create_folder_home(self):
response = self.client.post(
reverse("core:file_detail", kwargs={"file_id": self.subscriber.home.id}),
{"folder_name": "GUY_folder_test"},
)
assert response.status_code == 302
response = self.client.get(
reverse("core:file_detail", kwargs={"file_id": self.subscriber.home.id})
)
assert response.status_code == 200
assert "GUY_folder_test</a>" in str(response.content)
def test_upload_file_home(self):
with open("/bin/ls", "rb") as f:
response = self.client.post(
reverse(
"core:file_detail", kwargs={"file_id": self.subscriber.home.id}
),
{"file_field": f},
)
assert response.status_code == 302
response = self.client.get(
reverse("core:file_detail", kwargs={"file_id": self.subscriber.home.id})
)
assert response.status_code == 200
assert "ls</a>" in str(response.content)
@pytest.mark.django_db
class TestUserProfilePicture:
"""Test interactions with user's profile picture."""
@pytest.fixture
def user(self) -> User:
pict = foreign_key(Recipe(SithFile), one_to_one=True)
return subscriber_user.extend(profile_pict=pict).make()
@staticmethod
def delete_picture_request(user: User, client: Client):
return client.post(
reverse(
"core:file_delete",
kwargs={"file_id": user.profile_pict.pk, "popup": ""},
)
+ f"?next={user.get_absolute_url()}"
)
@pytest.mark.parametrize(
"user_factory",
[lambda: baker.make(User, is_superuser=True), board_user.make],
)
def test_delete_picture_successful(
self, user: User, user_factory: Callable[[], User], client: Client
):
"""Test that root and board members can delete a user's profile picture."""
cache.clear()
operator = user_factory()
client.force_login(operator)
res = self.delete_picture_request(user, client)
assert res.status_code == 302
assert res.url == user.get_absolute_url()
user.refresh_from_db()
assert user.profile_pict is None
@pytest.mark.parametrize(
"user_factory",
[lambda: baker.make(User), subscriber_user.make],
)
def test_delete_picture_unauthorized(
self, user: User, user_factory, client: Client
):
"""Test that regular users can't delete a user's profile picture."""
cache.clear()
operator = user_factory()
client.force_login(operator)
original_picture = user.profile_pict
res = self.delete_picture_request(user, client)
assert res.status_code == 403
user.refresh_from_db()
assert user.profile_pict is not None
assert user.profile_pict == original_picture
def test_user_cannot_delete_own_picture(self, user: User, client: Client):
"""Test that a user can't delete their own profile picture."""
cache.clear()
client.force_login(user)
original_picture = user.profile_pict
res = self.delete_picture_request(user, client)
assert res.status_code == 403
user.refresh_from_db()
assert user.profile_pict is not None
assert user.profile_pict == original_picture
def test_user_set_own_picture(self, user: User, client: Client):
"""Test that a user can set their own profile picture if they have none."""
user.profile_pict.delete()
user.profile_pict = None
user.save()
cache.clear()
client.force_login(user)
img = Image.new("RGB", (10, 10))
content = BytesIO()
img.save(content, format="JPEG")
name = str(uuid4())
res = client.post(
reverse("core:user_edit", kwargs={"user_id": user.pk}),
data={
# birthdate, email and tshirt_size are required by the form
"date_of_birth": "1990-01-01",
"email": f"{uuid4()}@gmail.com",
"tshirt_size": "M",
"profile_pict": SimpleUploadedFile(
f"{name}.jpg", content.getvalue(), content_type="image/jpeg"
),
},
)
assert res.status_code == 302
user.refresh_from_db()
assert user.profile_pict is not None
# uploaded images should be converted to WEBP
assert Image.open(user.profile_pict.file).format == "WEBP"
@pytest.mark.django_db
def test_apply_rights_recursively():
"""Test that the apply_rights_recursively method works as intended."""
files = [baker.make(SithFile)]
files.extend(baker.make(SithFile, _quantity=3, parent=files[0], _bulk_create=True))
files.extend(
baker.make(SithFile, _quantity=3, parent=iter(files[1:4]), _bulk_create=True)
)
files.extend(
baker.make(SithFile, _quantity=6, parent=cycle(files[4:7]), _bulk_create=True)
)
groups = list(baker.make(Group, _quantity=7))
files[0].view_groups.set(groups[:3])
files[0].edit_groups.set(groups[2:6])
# those groups should be erased after the function call
files[1].view_groups.set(groups[6:])
with assertNumQueries(10):
# 1 query for each level of depth (here 4)
# 1 query to get the view_groups of the first file
# 1 query to delete the previous view_groups
# 1 query apply the new view_groups
# same 3 queries for the edit_groups
files[0].apply_rights_recursively()
for file in SithFile.objects.filter(pk__in=[f.pk for f in files]).prefetch_related(
"view_groups", "edit_groups"
):
assert set(file.view_groups.all()) == set(groups[:3])
assert set(file.edit_groups.all()) == set(groups[2:6])

97
core/tests/test_user.py Normal file
View File

@ -0,0 +1,97 @@
from datetime import timedelta
from django.core.management import call_command
from django.test import TestCase
from django.urls import reverse
from django.utils.timezone import now
from model_bakery import baker, seq
from model_bakery.recipe import Recipe
from core.baker_recipes import subscriber_user
from core.models import User
class TestSearchUsers(TestCase):
@classmethod
def setUpTestData(cls):
User.objects.all().delete()
user_recipe = Recipe(
User,
first_name=seq("First", suffix="Name"),
last_name=seq("Last", suffix="Name"),
nick_name=seq("Nick", suffix="Name"),
)
cls.users = [
user_recipe.make(last_login=None),
*user_recipe.make(
last_login=seq(now() - timedelta(days=30), timedelta(days=1)),
_quantity=10,
_bulk_create=True,
),
]
call_command("update_index", "core", "--remove")
@classmethod
def tearDownClass(cls):
super().tearDownClass()
# restore the index
call_command("update_index", "core", "--remove")
class TestSearchUsersAPI(TestSearchUsers):
def test_order(self):
"""Test that users are ordered by last login date."""
self.client.force_login(subscriber_user.make())
response = self.client.get(reverse("api:search_users") + "?search=First")
assert response.status_code == 200
assert response.json()["count"] == 11
# The users are ordered by last login date, so we need to reverse the list
assert [r["id"] for r in response.json()["results"]] == [
u.id for u in self.users[::-1]
]
def test_search_case_insensitive(self):
"""Test that the search is case insensitive."""
self.client.force_login(subscriber_user.make())
expected = [u.id for u in self.users[::-1]]
for term in ["first", "First", "FIRST"]:
response = self.client.get(reverse("api:search_users") + f"?search={term}")
assert response.status_code == 200
assert response.json()["count"] == 11
assert [r["id"] for r in response.json()["results"]] == expected
def test_search_nick_name(self):
"""Test that the search can be done on the nick name."""
self.client.force_login(subscriber_user.make())
# this should return users with nicknames Nick11, Nick10 and Nick1
response = self.client.get(reverse("api:search_users") + "?search=Nick1")
assert response.status_code == 200
assert [r["id"] for r in response.json()["results"]] == [
self.users[10].id,
self.users[9].id,
self.users[0].id,
]
def test_search_special_characters(self):
"""Test that the search can be done on special characters."""
belix = baker.make(User, nick_name="Bélix")
call_command("update_index", "core")
self.client.force_login(subscriber_user.make())
# this should return users with first names First1 and First10
response = self.client.get(reverse("api:search_users") + "?search=bél")
assert response.status_code == 200
assert [r["id"] for r in response.json()["results"]] == [belix.id]
class TestSearchUsersView(TestSearchUsers):
"""Test the search user view (`GET /search`)."""
def test_page_ok(self):
"""Just test that the page loads."""
self.client.force_login(subscriber_user.make())
response = self.client.get(reverse("core:search"))
assert response.status_code == 200

View File

@ -357,7 +357,7 @@ class FileDeleteView(CanEditPropMixin, DeleteView):
def get_success_url(self): def get_success_url(self):
self.object.file.delete() # Doing it here or overloading delete() is the same, so let's do it here self.object.file.delete() # Doing it here or overloading delete() is the same, so let's do it here
if "next" in self.request.GET.keys(): if "next" in self.request.GET:
return self.request.GET["next"] return self.request.GET["next"]
if self.object.parent is None: if self.object.parent is None:
return reverse( return reverse(

View File

@ -85,7 +85,8 @@ def search_user(query):
SearchQuerySet() SearchQuerySet()
.models(User) .models(User)
.autocomplete(auto=query) .autocomplete(auto=query)
.order_by("-last_update")[:20] .order_by("-last_login")
.load_all()[:20]
) )
return [r.object for r in res] return [r.object for r in res]
except TypeError: except TypeError:

View File

@ -46,6 +46,7 @@ class CustomerAdmin(SearchModelAdmin):
@admin.register(BillingInfo) @admin.register(BillingInfo)
class BillingInfoAdmin(admin.ModelAdmin): class BillingInfoAdmin(admin.ModelAdmin):
list_display = ("first_name", "last_name", "address_1", "city", "country") list_display = ("first_name", "last_name", "address_1", "city", "country")
autocomplete_fields = ("customer",)
@admin.register(Counter) @admin.register(Counter)

View File

@ -2,6 +2,7 @@ from ajax_select import make_ajax_field
from ajax_select.fields import AutoCompleteSelectField, AutoCompleteSelectMultipleField from ajax_select.fields import AutoCompleteSelectField, AutoCompleteSelectMultipleField
from django import forms from django import forms
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from phonenumber_field.widgets import RegionalPhoneNumberWidget
from core.views.forms import NFCTextInput, SelectDate, SelectDateTime from core.views.forms import NFCTextInput, SelectDate, SelectDateTime
from counter.models import ( from counter.models import (
@ -26,7 +27,11 @@ class BillingInfoForm(forms.ModelForm):
"zip_code", "zip_code",
"city", "city",
"country", "country",
"phone_number",
] ]
widgets = {
"phone_number": RegionalPhoneNumberWidget,
}
class StudentCardForm(forms.ModelForm): class StudentCardForm(forms.ModelForm):

View File

@ -0,0 +1,20 @@
# Generated by Django 4.2.16 on 2024-09-26 10:28
import phonenumber_field.modelfields
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("counter", "0022_alter_product_icon"),
]
operations = [
migrations.AddField(
model_name="billinginfo",
name="phone_number",
field=phonenumber_field.modelfields.PhoneNumberField(
max_length=128, null=True, region=None, verbose_name="Phone number"
),
),
]

View File

@ -34,6 +34,7 @@ from django.utils import timezone
from django.utils.functional import cached_property from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django_countries.fields import CountryField from django_countries.fields import CountryField
from phonenumber_field.modelfields import PhoneNumberField
from accounting.models import CurrencyField from accounting.models import CurrencyField
from club.models import Club from club.models import Club
@ -176,6 +177,14 @@ class BillingInfo(models.Model):
city = models.CharField(_("City"), max_length=50) city = models.CharField(_("City"), max_length=50)
country = CountryField(blank_label=_("Country")) country = CountryField(blank_label=_("Country"))
# This table was created during the A22 semester.
# However, later on, CA asked for the phone number to be added to the billing info.
# As the table was already created, this new field had to be nullable,
# even tough it is required by the bank and shouldn't be null.
# If one day there is no null phone number remaining,
# please make the field non-nullable.
phone_number = PhoneNumberField(_("Phone number"), null=True, blank=False)
def __str__(self): def __str__(self):
return f"{self.first_name} {self.last_name}" return f"{self.first_name} {self.last_name}"
@ -192,6 +201,8 @@ class BillingInfo(models.Model):
"ZipCode": self.zip_code, "ZipCode": self.zip_code,
"City": self.city, "City": self.city,
"CountryCode": self.country.numeric, # ISO-3166-1 numeric code "CountryCode": self.country.numeric, # ISO-3166-1 numeric code
"MobilePhone": self.phone_number.as_national.replace(" ", ""),
"CountryCodeMobilePhone": f"+{self.phone_number.country_code}",
} }
} }
if self.address_2: if self.address_2:

View File

@ -315,6 +315,7 @@ class TestBillingInfo:
"zip_code": "34301", "zip_code": "34301",
"city": "Sète", "city": "Sète",
"country": "FR", "country": "FR",
"phone_number": "0612345678",
} }
def test_edit_infos(self, client: Client, payload: dict): def test_edit_infos(self, client: Client, payload: dict):
@ -356,7 +357,7 @@ class TestBillingInfo:
for key, val in payload.items(): for key, val in payload.items():
assert getattr(infos, key) == val assert getattr(infos, key) == val
def test_invalid_data(self, client: Client, payload): def test_invalid_data(self, client: Client, payload: dict[str, str]):
user = subscriber_user.make() user = subscriber_user.make()
client.force_login(user) client.force_login(user)
# address_1, zip_code and country are missing # address_1, zip_code and country are missing
@ -391,6 +392,60 @@ class TestBillingInfo:
) )
assert response.status_code == expected_code assert response.status_code == expected_code
@pytest.mark.parametrize(
"phone_number",
["+33612345678", "0612345678", "06 12 34 56 78", "06-12-34-56-78"],
)
def test_phone_number_format(
self, client: Client, payload: dict, phone_number: str
):
"""Test that various formats of phone numbers are accepted."""
user = subscriber_user.make()
client.force_login(user)
payload["phone_number"] = phone_number
response = client.put(
reverse("api:put_billing_info", args=[user.id]),
json.dumps(payload),
content_type="application/json",
)
assert response.status_code == 200
infos = BillingInfo.objects.get(customer__user=user)
assert infos.phone_number == "0612345678"
assert infos.phone_number.country_code == 33
def test_foreign_phone_number(self, client: Client, payload: dict):
"""Test that a foreign phone number is accepted."""
user = subscriber_user.make()
client.force_login(user)
payload["phone_number"] = "+49612345678"
response = client.put(
reverse("api:put_billing_info", args=[user.id]),
json.dumps(payload),
content_type="application/json",
)
assert response.status_code == 200
infos = BillingInfo.objects.get(customer__user=user)
assert infos.phone_number.as_national == "06123 45678"
assert infos.phone_number.country_code == 49
@pytest.mark.parametrize(
"phone_number", ["061234567a", "06 12 34 56", "061234567879", "azertyuiop"]
)
def test_invalid_phone_number(
self, client: Client, payload: dict, phone_number: str
):
"""Test that invalid phone numbers are rejected."""
user = subscriber_user.make()
client.force_login(user)
payload["phone_number"] = phone_number
response = client.put(
reverse("api:put_billing_info", args=[user.id]),
json.dumps(payload),
content_type="application/json",
)
assert response.status_code == 422
assert not BillingInfo.objects.filter(customer__user=user).exists()
class TestBarmanConnection(TestCase): class TestBarmanConnection(TestCase):
@classmethod @classmethod

View File

@ -1,6 +1,11 @@
from typing import Annotated
from ninja import ModelSchema, Schema from ninja import ModelSchema, Schema
from pydantic import Field, NonNegativeInt, PositiveInt, TypeAdapter from pydantic import Field, NonNegativeInt, PositiveInt, TypeAdapter
# from phonenumber_field.phonenumber import PhoneNumber
from pydantic_extra_types.phone_numbers import PhoneNumber, PhoneNumberValidator
from counter.models import BillingInfo from counter.models import BillingInfo
@ -31,3 +36,8 @@ class BillingInfoSchema(ModelSchema):
"country", "country",
] ]
fields_optional = ["customer"] fields_optional = ["customer"]
# for reasons described in the model, BillingInfo.phone_number
# in nullable, but null values shouldn't be actually allowed,
# so we force the field to be required
phone_number: Annotated[PhoneNumber, PhoneNumberValidator(default_region="FR")]

View File

@ -42,7 +42,16 @@ document.addEventListener("alpine:init", () => {
this.req_state = res.ok this.req_state = res.ok
? BillingInfoReqState.SUCCESS ? BillingInfoReqState.SUCCESS
: BillingInfoReqState.FAILURE; : BillingInfoReqState.FAILURE;
if (res.ok) { if (res.status === 422) {
const errors = (await res.json())["detail"].map((err) => err["loc"]).flat();
Array.from(form.querySelectorAll("input"))
.filter((elem) => errors.includes(elem.name))
.forEach((elem) => {
elem.setCustomValidity(gettext("Incorrect value"));
elem.reportValidity();
elem.oninput = () => elem.setCustomValidity("");
});
} else if (res.ok) {
Alpine.store("billing_inputs").fill(); Alpine.store("billing_inputs").fill();
} }
}, },

View File

@ -98,13 +98,20 @@
</form> </form>
</div> </div>
<br> <br>
{% if must_fill_billing_infos %} {% if billing_infos_state == BillingInfoState.EMPTY %}
<p> <div class="alert alert-yellow">
<i> {% trans %}You must fill your billing infos if you want to pay with your credit
{% trans %}You must fill your billing infos if you want to pay with your credit card{% endtrans %}
card{% endtrans %} </div>
</i> {% elif billing_infos_state == BillingInfoState.MISSING_PHONE_NUMBER %}
</p> <div class="alert alert-yellow">
{% trans %}
The Crédit Agricole changed its policy related to the billing
information that must be provided in order to pay with a credit card.
If you want to pay with your credit card, you must add a phone number
to the data you already provided.
{% endtrans %}
</div>
{% endif %} {% endif %}
<form method="post" action="{{ settings.SITH_EBOUTIC_ET_URL }}" name="bank-pay-form"> <form method="post" action="{{ settings.SITH_EBOUTIC_ET_URL }}" name="bank-pay-form">
<template x-data x-for="[key, value] in Object.entries($store.billing_inputs.data)"> <template x-data x-for="[key, value] in Object.entries($store.billing_inputs.data)">
@ -113,7 +120,7 @@
<input <input
type="submit" type="submit"
id="bank-submit-button" id="bank-submit-button"
{% if must_fill_billing_infos %}disabled="disabled"{% endif %} {% if billing_infos_state != BillingInfoState.VALID %}disabled="disabled"{% endif %}
value="{% trans %}Pay with credit card{% endtrans %}" value="{% trans %}Pay with credit card{% endtrans %}"
/> />
</form> </form>

View File

@ -16,6 +16,7 @@
import base64 import base64
import json import json
from datetime import datetime from datetime import datetime
from enum import Enum
import sentry_sdk import sentry_sdk
from cryptography.exceptions import InvalidSignature from cryptography.exceptions import InvalidSignature
@ -82,6 +83,12 @@ def payment_result(request, result: str) -> HttpResponse:
return render(request, "eboutic/eboutic_payment_result.jinja", context) return render(request, "eboutic/eboutic_payment_result.jinja", context)
class BillingInfoState(Enum):
VALID = 1
EMPTY = 2
MISSING_PHONE_NUMBER = 3
class EbouticCommand(LoginRequiredMixin, TemplateView): class EbouticCommand(LoginRequiredMixin, TemplateView):
template_name = "eboutic/eboutic_makecommand.jinja" template_name = "eboutic/eboutic_makecommand.jinja"
basket: Basket basket: Basket
@ -130,9 +137,16 @@ class EbouticCommand(LoginRequiredMixin, TemplateView):
default_billing_info = customer.billing_infos default_billing_info = customer.billing_infos
else: else:
kwargs["customer_amount"] = None kwargs["customer_amount"] = None
kwargs["must_fill_billing_infos"] = default_billing_info is None # make the enum available in the template
if not kwargs["must_fill_billing_infos"]: kwargs["BillingInfoState"] = BillingInfoState
# the user has already filled its billing_infos, thus we can if default_billing_info is None:
kwargs["billing_infos_state"] = BillingInfoState.EMPTY
elif default_billing_info.phone_number is None:
kwargs["billing_infos_state"] = BillingInfoState.MISSING_PHONE_NUMBER
else:
kwargs["billing_infos_state"] = BillingInfoState.VALID
if kwargs["billing_infos_state"] == BillingInfoState.VALID:
# the user has already filled all of its billing_infos, thus we can
# get it without expecting an error # get it without expecting an error
kwargs["billing_infos"] = dict(self.basket.get_e_transaction_data()) kwargs["billing_infos"] = dict(self.basket.get_e_transaction_data())
kwargs["basket"] = self.basket kwargs["basket"] = self.basket

View File

@ -6,7 +6,7 @@
msgid "" msgid ""
msgstr "" msgstr ""
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2024-09-17 12:21+0200\n" "POT-Creation-Date: 2024-09-26 17:51+0200\n"
"PO-Revision-Date: 2016-07-18\n" "PO-Revision-Date: 2016-07-18\n"
"Last-Translator: Skia <skia@libskia.so>\n" "Last-Translator: Skia <skia@libskia.so>\n"
"Language-Team: AE info <ae.info@utbm.fr>\n" "Language-Team: AE info <ae.info@utbm.fr>\n"
@ -18,8 +18,8 @@ msgstr ""
#: accounting/models.py:62 accounting/models.py:103 accounting/models.py:136 #: accounting/models.py:62 accounting/models.py:103 accounting/models.py:136
#: accounting/models.py:203 club/models.py:54 com/models.py:274 #: accounting/models.py:203 club/models.py:54 com/models.py:274
#: com/models.py:293 counter/models.py:209 counter/models.py:242 #: com/models.py:293 counter/models.py:213 counter/models.py:246
#: counter/models.py:377 forum/models.py:59 launderette/models.py:29 #: counter/models.py:381 forum/models.py:59 launderette/models.py:29
#: launderette/models.py:84 launderette/models.py:122 stock/models.py:36 #: launderette/models.py:84 launderette/models.py:122 stock/models.py:36
#: stock/models.py:57 stock/models.py:97 stock/models.py:125 #: stock/models.py:57 stock/models.py:97 stock/models.py:125
msgid "name" msgid "name"
@ -66,8 +66,8 @@ msgid "account number"
msgstr "numéro de compte" msgstr "numéro de compte"
#: accounting/models.py:109 accounting/models.py:140 club/models.py:344 #: accounting/models.py:109 accounting/models.py:140 club/models.py:344
#: com/models.py:74 com/models.py:259 com/models.py:299 counter/models.py:265 #: com/models.py:74 com/models.py:259 com/models.py:299 counter/models.py:269
#: counter/models.py:379 trombi/models.py:210 #: counter/models.py:383 trombi/models.py:210
msgid "club" msgid "club"
msgstr "club" msgstr "club"
@ -88,12 +88,12 @@ msgstr "Compte club"
msgid "%(club_account)s on %(bank_account)s" msgid "%(club_account)s on %(bank_account)s"
msgstr "%(club_account)s sur %(bank_account)s" msgstr "%(club_account)s sur %(bank_account)s"
#: accounting/models.py:201 club/models.py:350 counter/models.py:860 #: accounting/models.py:201 club/models.py:350 counter/models.py:864
#: election/models.py:16 launderette/models.py:179 #: election/models.py:16 launderette/models.py:179
msgid "start date" msgid "start date"
msgstr "date de début" msgstr "date de début"
#: accounting/models.py:202 club/models.py:351 counter/models.py:861 #: accounting/models.py:202 club/models.py:351 counter/models.py:865
#: election/models.py:17 #: election/models.py:17
msgid "end date" msgid "end date"
msgstr "date de fin" msgstr "date de fin"
@ -106,8 +106,8 @@ msgstr "est fermé"
msgid "club account" msgid "club account"
msgstr "compte club" msgstr "compte club"
#: accounting/models.py:212 accounting/models.py:272 counter/models.py:56 #: accounting/models.py:212 accounting/models.py:272 counter/models.py:57
#: counter/models.py:583 #: counter/models.py:587
msgid "amount" msgid "amount"
msgstr "montant" msgstr "montant"
@ -127,20 +127,20 @@ msgstr "numéro"
msgid "journal" msgid "journal"
msgstr "classeur" msgstr "classeur"
#: accounting/models.py:273 core/models.py:940 core/models.py:1442 #: accounting/models.py:273 core/models.py:940 core/models.py:1460
#: core/models.py:1487 core/models.py:1516 core/models.py:1540 #: core/models.py:1505 core/models.py:1534 core/models.py:1558
#: counter/models.py:593 counter/models.py:686 counter/models.py:896 #: counter/models.py:597 counter/models.py:690 counter/models.py:900
#: eboutic/models.py:57 eboutic/models.py:173 forum/models.py:311 #: eboutic/models.py:57 eboutic/models.py:173 forum/models.py:311
#: forum/models.py:412 stock/models.py:96 #: forum/models.py:412 stock/models.py:96
msgid "date" msgid "date"
msgstr "date" msgstr "date"
#: accounting/models.py:274 counter/models.py:211 counter/models.py:897 #: accounting/models.py:274 counter/models.py:215 counter/models.py:901
#: pedagogy/models.py:207 stock/models.py:99 #: pedagogy/models.py:207 stock/models.py:99
msgid "comment" msgid "comment"
msgstr "commentaire" msgstr "commentaire"
#: accounting/models.py:276 counter/models.py:595 counter/models.py:688 #: accounting/models.py:276 counter/models.py:599 counter/models.py:692
#: subscription/models.py:56 #: subscription/models.py:56
msgid "payment method" msgid "payment method"
msgstr "méthode de paiement" msgstr "méthode de paiement"
@ -166,8 +166,8 @@ msgid "accounting type"
msgstr "type comptable" msgstr "type comptable"
#: accounting/models.py:311 accounting/models.py:450 accounting/models.py:483 #: accounting/models.py:311 accounting/models.py:450 accounting/models.py:483
#: accounting/models.py:515 core/models.py:1515 core/models.py:1541 #: accounting/models.py:515 core/models.py:1533 core/models.py:1559
#: counter/models.py:652 #: counter/models.py:656
msgid "label" msgid "label"
msgstr "étiquette" msgstr "étiquette"
@ -266,7 +266,7 @@ msgstr ""
"Vous devez fournir soit un type comptable simplifié ou un type comptable " "Vous devez fournir soit un type comptable simplifié ou un type comptable "
"standard" "standard"
#: accounting/models.py:442 counter/models.py:252 pedagogy/models.py:41 #: accounting/models.py:442 counter/models.py:256 pedagogy/models.py:41
msgid "code" msgid "code"
msgstr "code" msgstr "code"
@ -370,7 +370,7 @@ msgstr "Compte en banque : "
#: core/templates/core/user_account_detail.jinja:66 #: core/templates/core/user_account_detail.jinja:66
#: core/templates/core/user_clubs.jinja:34 #: core/templates/core/user_clubs.jinja:34
#: core/templates/core/user_clubs.jinja:63 #: core/templates/core/user_clubs.jinja:63
#: core/templates/core/user_edit.jinja:57 #: core/templates/core/user_edit.jinja:62
#: core/templates/core/user_preferences.jinja:48 #: core/templates/core/user_preferences.jinja:48
#: counter/templates/counter/last_ops.jinja:35 #: counter/templates/counter/last_ops.jinja:35
#: counter/templates/counter/last_ops.jinja:65 #: counter/templates/counter/last_ops.jinja:65
@ -968,11 +968,11 @@ msgstr "Une action est requise"
msgid "You must specify at least an user or an email address" msgid "You must specify at least an user or an email address"
msgstr "vous devez spécifier au moins un utilisateur ou une adresse email" msgstr "vous devez spécifier au moins un utilisateur ou une adresse email"
#: club/forms.py:153 counter/forms.py:186 #: club/forms.py:153 counter/forms.py:187
msgid "Begin date" msgid "Begin date"
msgstr "Date de début" msgstr "Date de début"
#: club/forms.py:156 com/views.py:82 com/views.py:201 counter/forms.py:189 #: club/forms.py:156 com/views.py:82 com/views.py:201 counter/forms.py:190
#: election/views.py:167 subscription/views.py:38 #: election/views.py:167 subscription/views.py:38
msgid "End date" msgid "End date"
msgstr "Date de fin" msgstr "Date de fin"
@ -1058,7 +1058,7 @@ msgstr "Vous ne pouvez pas faire de boucles dans les clubs"
msgid "A club with that unix_name already exists" msgid "A club with that unix_name already exists"
msgstr "Un club avec ce nom UNIX existe déjà." msgstr "Un club avec ce nom UNIX existe déjà."
#: club/models.py:336 counter/models.py:851 counter/models.py:887 #: club/models.py:336 counter/models.py:855 counter/models.py:891
#: eboutic/models.py:53 eboutic/models.py:169 election/models.py:183 #: eboutic/models.py:53 eboutic/models.py:169 election/models.py:183
#: launderette/models.py:136 launderette/models.py:198 sas/models.py:270 #: launderette/models.py:136 launderette/models.py:198 sas/models.py:270
#: trombi/models.py:206 #: trombi/models.py:206
@ -1070,8 +1070,8 @@ msgstr "nom d'utilisateur"
msgid "role" msgid "role"
msgstr "rôle" msgstr "rôle"
#: club/models.py:358 core/models.py:89 counter/models.py:210 #: club/models.py:358 core/models.py:89 counter/models.py:214
#: counter/models.py:243 election/models.py:13 election/models.py:115 #: counter/models.py:247 election/models.py:13 election/models.py:115
#: election/models.py:188 forum/models.py:60 forum/models.py:244 #: election/models.py:188 forum/models.py:60 forum/models.py:244
msgid "description" msgid "description"
msgstr "description" msgstr "description"
@ -1440,7 +1440,7 @@ msgstr "résumé"
msgid "content" msgid "content"
msgstr "contenu" msgstr "contenu"
#: com/models.py:71 core/models.py:1485 launderette/models.py:92 #: com/models.py:71 core/models.py:1503 launderette/models.py:92
#: launderette/models.py:130 launderette/models.py:181 stock/models.py:74 #: launderette/models.py:130 launderette/models.py:181 stock/models.py:74
#: stock/models.py:129 #: stock/models.py:129
msgid "type" msgid "type"
@ -2217,7 +2217,7 @@ msgstr "Un utilisateur de ce nom d'utilisateur existe déjà"
#: core/templates/core/user_detail.jinja:110 #: core/templates/core/user_detail.jinja:110
#: core/templates/core/user_detail.jinja:112 #: core/templates/core/user_detail.jinja:112
#: core/templates/core/user_detail.jinja:113 #: core/templates/core/user_detail.jinja:113
#: core/templates/core/user_edit.jinja:16 #: core/templates/core/user_edit.jinja:21
#: election/templates/election/election_detail.jinja:132 #: election/templates/election/election_detail.jinja:132
#: election/templates/election/election_detail.jinja:134 #: election/templates/election/election_detail.jinja:134
#: forum/templates/forum/macros.jinja:104 #: forum/templates/forum/macros.jinja:104
@ -2250,7 +2250,7 @@ msgstr "avoir une notification pour chaque rechargement"
msgid "file name" msgid "file name"
msgstr "nom du fichier" msgstr "nom du fichier"
#: core/models.py:899 core/models.py:1234 #: core/models.py:899 core/models.py:1252
msgid "parent" msgid "parent"
msgstr "parent" msgstr "parent"
@ -2266,11 +2266,11 @@ msgstr "miniature"
msgid "owner" msgid "owner"
msgstr "propriétaire" msgstr "propriétaire"
#: core/models.py:932 core/models.py:1251 core/views/files.py:223 #: core/models.py:932 core/models.py:1269 core/views/files.py:223
msgid "edit group" msgid "edit group"
msgstr "groupe d'édition" msgstr "groupe d'édition"
#: core/models.py:935 core/models.py:1254 core/views/files.py:226 #: core/models.py:935 core/models.py:1272 core/views/files.py:226
msgid "view group" msgid "view group"
msgstr "groupe de vue" msgstr "groupe de vue"
@ -2316,11 +2316,11 @@ msgstr "Un fichier de ce nom existe déjà"
msgid "You must provide a file" msgid "You must provide a file"
msgstr "Vous devez fournir un fichier" msgstr "Vous devez fournir un fichier"
#: core/models.py:1217 #: core/models.py:1235
msgid "page unix name" msgid "page unix name"
msgstr "nom unix de la page" msgstr "nom unix de la page"
#: core/models.py:1223 #: core/models.py:1241
msgid "" msgid ""
"Enter a valid page name. This value may contain only unaccented letters, " "Enter a valid page name. This value may contain only unaccented letters, "
"numbers and ./+/-/_ characters." "numbers and ./+/-/_ characters."
@ -2328,55 +2328,55 @@ msgstr ""
"Entrez un nom de page correct. Uniquement des lettres non accentuées, " "Entrez un nom de page correct. Uniquement des lettres non accentuées, "
"numéros, et ./+/-/_" "numéros, et ./+/-/_"
#: core/models.py:1241 #: core/models.py:1259
msgid "page name" msgid "page name"
msgstr "nom de la page" msgstr "nom de la page"
#: core/models.py:1246 #: core/models.py:1264
msgid "owner group" msgid "owner group"
msgstr "groupe propriétaire" msgstr "groupe propriétaire"
#: core/models.py:1259 #: core/models.py:1277
msgid "lock user" msgid "lock user"
msgstr "utilisateur bloquant" msgstr "utilisateur bloquant"
#: core/models.py:1266 #: core/models.py:1284
msgid "lock_timeout" msgid "lock_timeout"
msgstr "décompte du déblocage" msgstr "décompte du déblocage"
#: core/models.py:1316 #: core/models.py:1334
msgid "Duplicate page" msgid "Duplicate page"
msgstr "Une page de ce nom existe déjà" msgstr "Une page de ce nom existe déjà"
#: core/models.py:1319 #: core/models.py:1337
msgid "Loop in page tree" msgid "Loop in page tree"
msgstr "Boucle dans l'arborescence des pages" msgstr "Boucle dans l'arborescence des pages"
#: core/models.py:1439 #: core/models.py:1457
msgid "revision" msgid "revision"
msgstr "révision" msgstr "révision"
#: core/models.py:1440 #: core/models.py:1458
msgid "page title" msgid "page title"
msgstr "titre de la page" msgstr "titre de la page"
#: core/models.py:1441 #: core/models.py:1459
msgid "page content" msgid "page content"
msgstr "contenu de la page" msgstr "contenu de la page"
#: core/models.py:1482 #: core/models.py:1500
msgid "url" msgid "url"
msgstr "url" msgstr "url"
#: core/models.py:1483 #: core/models.py:1501
msgid "param" msgid "param"
msgstr "param" msgstr "param"
#: core/models.py:1488 #: core/models.py:1506
msgid "viewed" msgid "viewed"
msgstr "vue" msgstr "vue"
#: core/models.py:1546 #: core/models.py:1564
msgid "operation type" msgid "operation type"
msgstr "type d'opération" msgstr "type d'opération"
@ -2474,7 +2474,7 @@ msgstr "Forum"
msgid "Gallery" msgid "Gallery"
msgstr "Photos" msgstr "Photos"
#: core/templates/core/base.jinja:225 counter/models.py:387 #: core/templates/core/base.jinja:225 counter/models.py:391
#: counter/templates/counter/counter_list.jinja:11 #: counter/templates/counter/counter_list.jinja:11
#: eboutic/templates/eboutic/eboutic_main.jinja:4 #: eboutic/templates/eboutic/eboutic_main.jinja:4
#: eboutic/templates/eboutic/eboutic_main.jinja:22 #: eboutic/templates/eboutic/eboutic_main.jinja:22
@ -2693,7 +2693,7 @@ msgid "Edit group"
msgstr "Éditer le groupe" msgstr "Éditer le groupe"
#: core/templates/core/group_edit.jinja:9 #: core/templates/core/group_edit.jinja:9
#: core/templates/core/user_edit.jinja:268 #: core/templates/core/user_edit.jinja:170
#: core/templates/core/user_group.jinja:13 #: core/templates/core/user_group.jinja:13
#: pedagogy/templates/pedagogy/uv_edit.jinja:36 #: pedagogy/templates/pedagogy/uv_edit.jinja:36
msgid "Update" msgid "Update"
@ -3184,39 +3184,35 @@ msgstr "Aucun cadeau donné pour l'instant"
msgid "Edit user" msgid "Edit user"
msgstr "Éditer l'utilisateur" msgstr "Éditer l'utilisateur"
#: core/templates/core/user_edit.jinja:36 #: core/templates/core/user_edit.jinja:41
msgid "Enable camera" msgid "Enable camera"
msgstr "Activer la caméra" msgstr "Activer la caméra"
#: core/templates/core/user_edit.jinja:44 #: core/templates/core/user_edit.jinja:49
msgid "Take a picture" msgid "Take a picture"
msgstr "Prendre une photo" msgstr "Prendre une photo"
#: core/templates/core/user_edit.jinja:64 #: core/templates/core/user_edit.jinja:69
msgid "To edit your profile picture, ask a member of the AE" msgid "To edit your profile picture, ask a member of the AE"
msgstr "Pour changer votre photo de profil, demandez à un membre de l'AE" msgstr "Pour changer votre photo de profil, demandez à un membre de l'AE"
#: core/templates/core/user_edit.jinja:173 #: core/templates/core/user_edit.jinja:98
msgid "captured"
msgstr "capturé"
#: core/templates/core/user_edit.jinja:196
msgid "Edit user profile" msgid "Edit user profile"
msgstr "Éditer le profil de l'utilisateur" msgstr "Éditer le profil de l'utilisateur"
#: core/templates/core/user_edit.jinja:258 #: core/templates/core/user_edit.jinja:160
msgid "Change my password" msgid "Change my password"
msgstr "Changer mon mot de passe" msgstr "Changer mon mot de passe"
#: core/templates/core/user_edit.jinja:263 #: core/templates/core/user_edit.jinja:165
msgid "Change user password" msgid "Change user password"
msgstr "Changer le mot de passe" msgstr "Changer le mot de passe"
#: core/templates/core/user_edit.jinja:273 #: core/templates/core/user_edit.jinja:175
msgid "Username:" msgid "Username:"
msgstr "Nom d'utilisateur : " msgstr "Nom d'utilisateur : "
#: core/templates/core/user_edit.jinja:276 #: core/templates/core/user_edit.jinja:178
msgid "Account number:" msgid "Account number:"
msgstr "Numéro de compte : " msgstr "Numéro de compte : "
@ -3350,7 +3346,7 @@ msgstr "Achats"
msgid "Product top 10" msgid "Product top 10"
msgstr "Top 10 produits" msgstr "Top 10 produits"
#: core/templates/core/user_stats.jinja:43 counter/forms.py:200 #: core/templates/core/user_stats.jinja:43 counter/forms.py:201
msgid "Product" msgid "Product"
msgstr "Produit" msgstr "Produit"
@ -3395,7 +3391,7 @@ msgstr "Cotisations"
msgid "Subscription stats" msgid "Subscription stats"
msgstr "Statistiques de cotisation" msgstr "Statistiques de cotisation"
#: core/templates/core/user_tools.jinja:48 counter/forms.py:159 #: core/templates/core/user_tools.jinja:48 counter/forms.py:160
#: counter/views.py:728 #: counter/views.py:728
msgid "Counters" msgid "Counters"
msgstr "Comptoirs" msgstr "Comptoirs"
@ -3654,7 +3650,7 @@ msgstr "Parrain / Marraine"
msgid "Godchild" msgid "Godchild"
msgstr "Fillot / Fillote" msgstr "Fillot / Fillote"
#: core/views/forms.py:338 counter/forms.py:67 trombi/views.py:149 #: core/views/forms.py:338 counter/forms.py:68 trombi/views.py:149
msgid "Select user" msgid "Select user"
msgstr "Choisir un utilisateur" msgstr "Choisir un utilisateur"
@ -3713,24 +3709,24 @@ msgstr "Photos"
msgid "Galaxy" msgid "Galaxy"
msgstr "Galaxie" msgstr "Galaxie"
#: counter/apps.py:30 counter/models.py:403 counter/models.py:857 #: counter/apps.py:30 counter/models.py:407 counter/models.py:861
#: counter/models.py:893 launderette/models.py:32 stock/models.py:39 #: counter/models.py:897 launderette/models.py:32 stock/models.py:39
msgid "counter" msgid "counter"
msgstr "comptoir" msgstr "comptoir"
#: counter/forms.py:48 #: counter/forms.py:49
msgid "This UID is invalid" msgid "This UID is invalid"
msgstr "Cet UID est invalide" msgstr "Cet UID est invalide"
#: counter/forms.py:89 #: counter/forms.py:90
msgid "User not found" msgid "User not found"
msgstr "Utilisateur non trouvé" msgstr "Utilisateur non trouvé"
#: counter/forms.py:145 #: counter/forms.py:146
msgid "Parent product" msgid "Parent product"
msgstr "Produit parent" msgstr "Produit parent"
#: counter/forms.py:151 #: counter/forms.py:152
msgid "Buying groups" msgid "Buying groups"
msgstr "Groupes d'achat" msgstr "Groupes d'achat"
@ -3738,165 +3734,169 @@ msgstr "Groupes d'achat"
msgid "Ecocup regularization" msgid "Ecocup regularization"
msgstr "Régularization des ecocups" msgstr "Régularization des ecocups"
#: counter/models.py:55 #: counter/models.py:56
msgid "account id" msgid "account id"
msgstr "numéro de compte" msgstr "numéro de compte"
#: counter/models.py:57 #: counter/models.py:58
msgid "recorded product" msgid "recorded product"
msgstr "produits consignés" msgstr "produits consignés"
#: counter/models.py:60 #: counter/models.py:61
msgid "customer" msgid "customer"
msgstr "client" msgstr "client"
#: counter/models.py:61 #: counter/models.py:62
msgid "customers" msgid "customers"
msgstr "clients" msgstr "clients"
#: counter/models.py:73 counter/views.py:309 #: counter/models.py:74 counter/views.py:309
msgid "Not enough money" msgid "Not enough money"
msgstr "Solde insuffisant" msgstr "Solde insuffisant"
#: counter/models.py:171 #: counter/models.py:172
msgid "First name" msgid "First name"
msgstr "Prénom" msgstr "Prénom"
#: counter/models.py:172 #: counter/models.py:173
msgid "Last name" msgid "Last name"
msgstr "Nom de famille" msgstr "Nom de famille"
#: counter/models.py:173 #: counter/models.py:174
msgid "Address 1" msgid "Address 1"
msgstr "Adresse 1" msgstr "Adresse 1"
#: counter/models.py:174 #: counter/models.py:175
msgid "Address 2" msgid "Address 2"
msgstr "Adresse 2" msgstr "Adresse 2"
#: counter/models.py:175 #: counter/models.py:176
msgid "Zip code" msgid "Zip code"
msgstr "Code postal" msgstr "Code postal"
#: counter/models.py:176 #: counter/models.py:177
msgid "City" msgid "City"
msgstr "Ville" msgstr "Ville"
#: counter/models.py:177 #: counter/models.py:178
msgid "Country" msgid "Country"
msgstr "Pays" msgstr "Pays"
#: counter/models.py:221 counter/models.py:247 #: counter/models.py:179
msgid "Phone number"
msgstr "Numéro de téléphone"
#: counter/models.py:225 counter/models.py:251
msgid "product type" msgid "product type"
msgstr "type du produit" msgstr "type du produit"
#: counter/models.py:253 #: counter/models.py:257
msgid "purchase price" msgid "purchase price"
msgstr "prix d'achat" msgstr "prix d'achat"
#: counter/models.py:254 #: counter/models.py:258
msgid "selling price" msgid "selling price"
msgstr "prix de vente" msgstr "prix de vente"
#: counter/models.py:255 #: counter/models.py:259
msgid "special selling price" msgid "special selling price"
msgstr "prix de vente spécial" msgstr "prix de vente spécial"
#: counter/models.py:262 #: counter/models.py:266
msgid "icon" msgid "icon"
msgstr "icône" msgstr "icône"
#: counter/models.py:267 #: counter/models.py:271
msgid "limit age" msgid "limit age"
msgstr "âge limite" msgstr "âge limite"
#: counter/models.py:268 #: counter/models.py:272
msgid "tray price" msgid "tray price"
msgstr "prix plateau" msgstr "prix plateau"
#: counter/models.py:272 #: counter/models.py:276
msgid "parent product" msgid "parent product"
msgstr "produit parent" msgstr "produit parent"
#: counter/models.py:278 #: counter/models.py:282
msgid "buying groups" msgid "buying groups"
msgstr "groupe d'achat" msgstr "groupe d'achat"
#: counter/models.py:280 election/models.py:50 #: counter/models.py:284 election/models.py:50
msgid "archived" msgid "archived"
msgstr "archivé" msgstr "archivé"
#: counter/models.py:283 counter/models.py:993 #: counter/models.py:287 counter/models.py:997
msgid "product" msgid "product"
msgstr "produit" msgstr "produit"
#: counter/models.py:382 #: counter/models.py:386
msgid "products" msgid "products"
msgstr "produits" msgstr "produits"
#: counter/models.py:385 #: counter/models.py:389
msgid "counter type" msgid "counter type"
msgstr "type de comptoir" msgstr "type de comptoir"
#: counter/models.py:387 #: counter/models.py:391
msgid "Bar" msgid "Bar"
msgstr "Bar" msgstr "Bar"
#: counter/models.py:387 #: counter/models.py:391
msgid "Office" msgid "Office"
msgstr "Bureau" msgstr "Bureau"
#: counter/models.py:390 #: counter/models.py:394
msgid "sellers" msgid "sellers"
msgstr "vendeurs" msgstr "vendeurs"
#: counter/models.py:398 launderette/models.py:192 #: counter/models.py:402 launderette/models.py:192
msgid "token" msgid "token"
msgstr "jeton" msgstr "jeton"
#: counter/models.py:601 #: counter/models.py:605
msgid "bank" msgid "bank"
msgstr "banque" msgstr "banque"
#: counter/models.py:603 counter/models.py:693 #: counter/models.py:607 counter/models.py:697
msgid "is validated" msgid "is validated"
msgstr "est validé" msgstr "est validé"
#: counter/models.py:606 #: counter/models.py:610
msgid "refilling" msgid "refilling"
msgstr "rechargement" msgstr "rechargement"
#: counter/models.py:670 eboutic/models.py:227 #: counter/models.py:674 eboutic/models.py:227
msgid "unit price" msgid "unit price"
msgstr "prix unitaire" msgstr "prix unitaire"
#: counter/models.py:671 counter/models.py:973 eboutic/models.py:228 #: counter/models.py:675 counter/models.py:977 eboutic/models.py:228
msgid "quantity" msgid "quantity"
msgstr "quantité" msgstr "quantité"
#: counter/models.py:690 #: counter/models.py:694
msgid "Sith account" msgid "Sith account"
msgstr "Compte utilisateur" msgstr "Compte utilisateur"
#: counter/models.py:690 sith/settings.py:405 sith/settings.py:410 #: counter/models.py:694 sith/settings.py:405 sith/settings.py:410
#: sith/settings.py:430 #: sith/settings.py:430
msgid "Credit card" msgid "Credit card"
msgstr "Carte bancaire" msgstr "Carte bancaire"
#: counter/models.py:696 #: counter/models.py:700
msgid "selling" msgid "selling"
msgstr "vente" msgstr "vente"
#: counter/models.py:800 #: counter/models.py:804
msgid "Unknown event" msgid "Unknown event"
msgstr "Événement inconnu" msgstr "Événement inconnu"
#: counter/models.py:801 #: counter/models.py:805
#, python-format #, python-format
msgid "Eticket bought for the event %(event)s" msgid "Eticket bought for the event %(event)s"
msgstr "Eticket acheté pour l'événement %(event)s" msgstr "Eticket acheté pour l'événement %(event)s"
#: counter/models.py:803 counter/models.py:826 #: counter/models.py:807 counter/models.py:830
#, python-format #, python-format
msgid "" msgid ""
"You bought an eticket for the event %(event)s.\n" "You bought an eticket for the event %(event)s.\n"
@ -3908,63 +3908,63 @@ msgstr ""
"Vous pouvez également retrouver tous vos e-tickets sur votre page de compte " "Vous pouvez également retrouver tous vos e-tickets sur votre page de compte "
"%(url)s." "%(url)s."
#: counter/models.py:862 #: counter/models.py:866
msgid "last activity date" msgid "last activity date"
msgstr "dernière activité" msgstr "dernière activité"
#: counter/models.py:865 #: counter/models.py:869
msgid "permanency" msgid "permanency"
msgstr "permanence" msgstr "permanence"
#: counter/models.py:898 #: counter/models.py:902
msgid "emptied" msgid "emptied"
msgstr "coffre vidée" msgstr "coffre vidée"
#: counter/models.py:901 #: counter/models.py:905
msgid "cash register summary" msgid "cash register summary"
msgstr "relevé de caisse" msgstr "relevé de caisse"
#: counter/models.py:969 #: counter/models.py:973
msgid "cash summary" msgid "cash summary"
msgstr "relevé" msgstr "relevé"
#: counter/models.py:972 #: counter/models.py:976
msgid "value" msgid "value"
msgstr "valeur" msgstr "valeur"
#: counter/models.py:975 #: counter/models.py:979
msgid "check" msgid "check"
msgstr "chèque" msgstr "chèque"
#: counter/models.py:977 #: counter/models.py:981
msgid "True if this is a bank check, else False" msgid "True if this is a bank check, else False"
msgstr "Vrai si c'est un chèque, sinon Faux." msgstr "Vrai si c'est un chèque, sinon Faux."
#: counter/models.py:981 #: counter/models.py:985
msgid "cash register summary item" msgid "cash register summary item"
msgstr "élément de relevé de caisse" msgstr "élément de relevé de caisse"
#: counter/models.py:997 #: counter/models.py:1001
msgid "banner" msgid "banner"
msgstr "bannière" msgstr "bannière"
#: counter/models.py:999 #: counter/models.py:1003
msgid "event date" msgid "event date"
msgstr "date de l'événement" msgstr "date de l'événement"
#: counter/models.py:1001 #: counter/models.py:1005
msgid "event title" msgid "event title"
msgstr "titre de l'événement" msgstr "titre de l'événement"
#: counter/models.py:1003 #: counter/models.py:1007
msgid "secret" msgid "secret"
msgstr "secret" msgstr "secret"
#: counter/models.py:1042 #: counter/models.py:1046
msgid "uid" msgid "uid"
msgstr "uid" msgstr "uid"
#: counter/models.py:1047 #: counter/models.py:1051
msgid "student cards" msgid "student cards"
msgstr "cartes étudiante" msgstr "cartes étudiante"
@ -4439,40 +4439,58 @@ msgstr "Solde restant : "
msgid "Billing information" msgid "Billing information"
msgstr "Informations de facturation" msgstr "Informations de facturation"
#: eboutic/templates/eboutic/eboutic_makecommand.jinja:104 #: eboutic/templates/eboutic/eboutic_makecommand.jinja:103
msgid "" msgid ""
"You must fill your billing infos if you want to pay with your credit\n" "You must fill your billing infos if you want to pay with your credit\n"
" card" " card"
msgstr "" msgstr ""
"Vous devez renseigner vos coordonnées de facturation si vous voulez payer " "Vous devez renseigner vos coordonnées de facturation si vous voulez payer "
"par carte bancaire" "par carte bancaire"
#: eboutic/templates/eboutic/eboutic_makecommand.jinja:117 #: eboutic/templates/eboutic/eboutic_makecommand.jinja:108
msgid ""
"\n"
" The Crédit Agricole changed its policy related to the "
"billing\n"
" information that must be provided in order to pay with a "
"credit card.\n"
" If you want to pay with your credit card, you must add a "
"phone number\n"
" to the data you already provided.\n"
" "
msgstr ""
"\n"
"Le Crédit Agricole a changé sa politique relative aux informations à "
"fournir pour effectuer un paiement par carte bancaire. De ce fait, si vous "
"souhaitez payer par carte, vous devez rajouter un numéro de téléphone aux "
"données que vous aviez déjà fourni."
#: eboutic/templates/eboutic/eboutic_makecommand.jinja:124
msgid "Pay with credit card" msgid "Pay with credit card"
msgstr "Payer avec une carte bancaire" msgstr "Payer avec une carte bancaire"
#: eboutic/templates/eboutic/eboutic_makecommand.jinja:122 #: eboutic/templates/eboutic/eboutic_makecommand.jinja:129
msgid "" msgid ""
"AE account payment disabled because your basket contains refilling items." "AE account payment disabled because your basket contains refilling items."
msgstr "" msgstr ""
"Paiement par compte AE désactivé parce que votre panier contient des bons de " "Paiement par compte AE désactivé parce que votre panier contient des bons de "
"rechargement." "rechargement."
#: eboutic/templates/eboutic/eboutic_makecommand.jinja:124 #: eboutic/templates/eboutic/eboutic_makecommand.jinja:131
msgid "" msgid ""
"AE account payment disabled because you do not have enough money remaining." "AE account payment disabled because you do not have enough money remaining."
msgstr "" msgstr ""
"Paiement par compte AE désactivé parce que votre solde est insuffisant." "Paiement par compte AE désactivé parce que votre solde est insuffisant."
#: eboutic/templates/eboutic/eboutic_makecommand.jinja:129 #: eboutic/templates/eboutic/eboutic_makecommand.jinja:136
msgid "Pay with Sith account" msgid "Pay with Sith account"
msgstr "Payer avec un compte AE" msgstr "Payer avec un compte AE"
#: eboutic/templates/eboutic/eboutic_makecommand.jinja:140 #: eboutic/templates/eboutic/eboutic_makecommand.jinja:147
msgid "Billing info registration success" msgid "Billing info registration success"
msgstr "Informations de facturation enregistrées" msgstr "Informations de facturation enregistrées"
#: eboutic/templates/eboutic/eboutic_makecommand.jinja:141 #: eboutic/templates/eboutic/eboutic_makecommand.jinja:148
msgid "Billing info registration failure" msgid "Billing info registration failure"
msgstr "Echec de l'enregistrement des informations de facturation." msgstr "Echec de l'enregistrement des informations de facturation."
@ -6325,3 +6343,6 @@ msgstr "Vous ne pouvez plus écrire de commentaires, la date est passée."
#, python-format #, python-format
msgid "Maximum characters: %(max_length)s" msgid "Maximum characters: %(max_length)s"
msgstr "Nombre de caractères max: %(max_length)s" msgstr "Nombre de caractères max: %(max_length)s"
#~ msgid "captured"
#~ msgstr "capturé"

View File

@ -7,7 +7,7 @@
msgid "" msgid ""
msgstr "" msgstr ""
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2024-09-03 15:22+0200\n" "POT-Creation-Date: 2024-09-27 22:32+0200\n"
"PO-Revision-Date: 2024-09-17 11:54+0200\n" "PO-Revision-Date: 2024-09-17 11:54+0200\n"
"Last-Translator: Sli <antoine@bartuccio.fr>\n" "Last-Translator: Sli <antoine@bartuccio.fr>\n"
"Language-Team: AE info <ae.info@utbm.fr>\n" "Language-Team: AE info <ae.info@utbm.fr>\n"
@ -16,9 +16,24 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n" "Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n" "Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n > 1);\n" "Plural-Forms: nplurals=2; plural=(n > 1);\n"
#: core/static/user/js/family_graph.js:230
#: core/static/user/js/family_graph.js:233
msgid "family_tree.%(extension)s" msgid "family_tree.%(extension)s"
msgstr "arbre_genealogique.%(extension)s" msgstr "arbre_genealogique.%(extension)s"
#: sas/static/sas/js/picture.js:52
#: core/static/user/js/user_edit.js:93
#, javascript-format
msgid "captured.%s"
msgstr "capture.%s"
#: eboutic/static/eboutic/js/makecommand.js:50
msgid "Incorrect value"
msgstr "Valeur incorrecte"
#: sas/static/sas/js/viewer.js:196
msgid "Couldn't moderate picture"
msgstr "Echec de la suppression de la photo"
#: sas/static/sas/js/viewer.js:209
msgid "Couldn't delete picture" msgid "Couldn't delete picture"
msgstr "Echec de la suppression de la photo" msgstr "Echec de la suppression de la photo"

29
poetry.lock generated
View File

@ -1813,6 +1813,33 @@ files = [
[package.dependencies] [package.dependencies]
typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0" typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0"
[[package]]
name = "pydantic-extra-types"
version = "2.9.0"
description = "Extra Pydantic types."
optional = false
python-versions = ">=3.8"
files = []
develop = false
[package.dependencies]
pydantic = ">=2.5.2"
typing-extensions = "*"
[package.extras]
all = ["pendulum (>=3.0.0,<4.0.0)", "phonenumbers (>=8,<9)", "pycountry (>=23)", "python-ulid (>=1,<2)", "python-ulid (>=1,<3)", "pytz (>=2024.1)", "semver (>=3.0.2)", "semver (>=3.0.2,<3.1.0)", "tzdata (>=2024.1)"]
pendulum = ["pendulum (>=3.0.0,<4.0.0)"]
phonenumbers = ["phonenumbers (>=8,<9)"]
pycountry = ["pycountry (>=23)"]
python-ulid = ["python-ulid (>=1,<2)", "python-ulid (>=1,<3)"]
semver = ["semver (>=3.0.2)"]
[package.source]
type = "git"
url = "https://github.com/pydantic/pydantic-extra-types.git"
reference = "HEAD"
resolved_reference = "58db4b096d7c90566d3d48d51b4665c01a591df6"
[[package]] [[package]]
name = "pygments" name = "pygments"
version = "2.18.0" version = "2.18.0"
@ -2626,4 +2653,4 @@ filelock = ">=3.4"
[metadata] [metadata]
lock-version = "2.0" lock-version = "2.0"
python-versions = "^3.12" python-versions = "^3.12"
content-hash = "b6202203d272cecdb607ea8ebc1ba12dd8369e4f387f65692c4a9681915e6f48" content-hash = "c9c49497cc576b24c96ea914b74ef5c3a0c2981c488a599752f05aabb575f8d8"

View File

@ -45,6 +45,10 @@ dict2xml = "^1.7.3"
Sphinx = "^5" # Needed for building xapian Sphinx = "^5" # Needed for building xapian
tomli = "^2.0.1" tomli = "^2.0.1"
django-honeypot = "^1.2.1" django-honeypot = "^1.2.1"
# When I introduced pydantic-extra-types, I needed *right now*
# the PhoneNumberValidator class which was on the master branch but not released yet.
# Once it's released, switch this to a regular version.
pydantic-extra-types = { git = "https://github.com/pydantic/pydantic-extra-types.git", rev = "58db4b0" }
[tool.poetry.group.prod.dependencies] [tool.poetry.group.prod.dependencies]
# deps used in prod, but unnecessary for development # deps used in prod, but unnecessary for development

View File

@ -201,7 +201,7 @@ document.addEventListener("alpine:init", () => {
}, },
async delete_picture() { async delete_picture() {
const res = await fetch(`/api/sas/picture/${this.current_picture.id}/`, { const res = await fetch(`/api/sas/picture/${this.current_picture.id}`, {
method: "DELETE", method: "DELETE",
}); });
if (!res.ok) { if (!res.ok) {