mirror of
https://github.com/ae-utbm/sith.git
synced 2024-11-22 06:03:20 +00:00
Merge pull request #849 from ae-utbm/taiste
New 3DSv2 fields and Bugfixes
This commit is contained in:
commit
3548deebf6
11
core/api.py
11
core/api.py
@ -2,6 +2,7 @@ from typing import Annotated
|
||||
|
||||
import annotated_types
|
||||
from django.conf import settings
|
||||
from django.db.models import F
|
||||
from django.http import HttpResponse
|
||||
from ninja import Query
|
||||
from ninja_extra import ControllerBase, api_controller, paginate, route
|
||||
@ -49,10 +50,16 @@ class UserController(ControllerBase):
|
||||
def fetch_profiles(self, pks: Query[set[int]]):
|
||||
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)
|
||||
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)]
|
||||
|
@ -1,9 +1,11 @@
|
||||
from datetime import timedelta
|
||||
|
||||
from django.conf import settings
|
||||
from django.utils.timezone import now
|
||||
from model_bakery import seq
|
||||
from model_bakery.recipe import Recipe, related
|
||||
|
||||
from club.models import Membership
|
||||
from core.models import User
|
||||
from subscription.models import Subscription
|
||||
|
||||
@ -24,9 +26,27 @@ subscriber_user = Recipe(
|
||||
last_name=seq("user "),
|
||||
subscriptions=related(active_subscription),
|
||||
)
|
||||
"""A user with an active subscription."""
|
||||
|
||||
old_subscriber_user = Recipe(
|
||||
User,
|
||||
first_name="old subscriber",
|
||||
last_name=seq("user "),
|
||||
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."""
|
||||
|
@ -1057,20 +1057,38 @@ class SithFile(models.Model):
|
||||
if self.is_file and (self.file is None or self.file == ""):
|
||||
raise ValidationError(_("You must provide a file"))
|
||||
|
||||
def apply_rights_recursively(self, *, only_folders=False):
|
||||
children = self.children.all()
|
||||
if only_folders:
|
||||
children = children.filter(is_folder=True)
|
||||
for c in children:
|
||||
c.copy_rights()
|
||||
c.apply_rights_recursively(only_folders=only_folders)
|
||||
def apply_rights_recursively(self, *, only_folders: bool = False) -> None:
|
||||
"""Apply the rights of this file to all children recursively.
|
||||
|
||||
Args:
|
||||
only_folders: If True, only apply the rights to SithFiles that are 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):
|
||||
"""Copy, if possible, the rights of the parent folder."""
|
||||
if self.parent is not None:
|
||||
self.edit_groups.set(self.parent.edit_groups.all())
|
||||
self.view_groups.set(self.parent.view_groups.all())
|
||||
self.save()
|
||||
|
||||
def move_to(self, parent):
|
||||
"""Move a file to a new parent.
|
||||
|
@ -66,7 +66,6 @@ class UserFilterSchema(FilterSchema):
|
||||
SearchQuerySet()
|
||||
.models(User)
|
||||
.autocomplete(auto=slugify(value).replace("-", " "))
|
||||
.order_by("-last_update")
|
||||
.values_list("pk", flat=True)
|
||||
)
|
||||
)
|
||||
|
@ -33,6 +33,9 @@ class UserIndex(indexes.SearchIndex, indexes.Indexable):
|
||||
text = indexes.CharField(document=True, use_template=True)
|
||||
auto = indexes.EdgeNgramField(use_template=True)
|
||||
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):
|
||||
return User
|
||||
|
@ -108,7 +108,8 @@ function update_query_string(key, value, action = History.REPLACE, url = null) {
|
||||
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,
|
||||
* 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))
|
||||
);
|
||||
}
|
||||
results.push(...await Promise.all(promises))
|
||||
results.push(...(await Promise.all(promises)).flat())
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
@ -306,6 +306,12 @@ a:not(.button) {
|
||||
align-items: center;
|
||||
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 {
|
||||
background-color: rgb(245, 255, 245);
|
||||
color: rgb(3, 84, 63);
|
||||
|
@ -77,11 +77,11 @@ function create_graph(container, data, active_user_id) {
|
||||
fit: true,
|
||||
klay: {
|
||||
addUnnecessaryBendpoints: true,
|
||||
direction: 'DOWN',
|
||||
nodePlacement: 'INTERACTIVE',
|
||||
layoutHierarchy: true
|
||||
}
|
||||
}
|
||||
direction: "DOWN",
|
||||
nodePlacement: "INTERACTIVE",
|
||||
layoutHierarchy: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
let active_user = cy
|
||||
.getElementById(active_user_id)
|
||||
@ -178,7 +178,9 @@ document.addEventListener("alpine:init", () => {
|
||||
typeof depth_min === "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;
|
||||
}
|
||||
|
||||
@ -194,7 +196,7 @@ document.addEventListener("alpine:init", () => {
|
||||
loading: false,
|
||||
godfathers_depth: get_initial_depth("godfathers_depth"),
|
||||
godchildren_depth: get_initial_depth("godchildren_depth"),
|
||||
reverse: initialUrlParams.get("reverse")?.toLowerCase?.() === 'true',
|
||||
reverse: initialUrlParams.get("reverse")?.toLowerCase?.() === "true",
|
||||
graph: undefined,
|
||||
graph_data: {},
|
||||
|
||||
@ -227,7 +229,11 @@ document.addEventListener("alpine:init", () => {
|
||||
async screenshot() {
|
||||
const link = document.createElement("a");
|
||||
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);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
|
110
core/static/user/js/user_edit.js
Normal file
110
core/static/user/js/user_edit.js
Normal 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;
|
||||
},
|
||||
});
|
||||
}
|
@ -8,7 +8,12 @@
|
||||
<link rel="stylesheet" href="{{ scss('user/user_edit.scss') }}">
|
||||
{%- endblock -%}
|
||||
|
||||
{% block additional_js %}
|
||||
<script defer src="{{ static("user/js/user_edit.js") }}"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% 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-display" :aria-busy="loading" :class="{ 'camera-error': is_camera_error }">
|
||||
<img
|
||||
@ -50,8 +55,8 @@
|
||||
<div>
|
||||
{{ form[field_name] }}
|
||||
<button class="btn btn-red" @click.prevent="delete_picture()"
|
||||
{%- if not (user.is_root and form.instance[field_name]) -%}
|
||||
:disabled="picture == null"
|
||||
{%- if not (this_picture and this_picture.is_owned_by(user)) -%}
|
||||
:disabled="!picture"
|
||||
{%- endif -%}
|
||||
x-cloak
|
||||
>
|
||||
@ -68,128 +73,25 @@
|
||||
</div>
|
||||
</div>
|
||||
<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", () => {
|
||||
Alpine.data("camera_{{ field_name }}", () => ({
|
||||
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() {
|
||||
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;
|
||||
},
|
||||
|
||||
}));
|
||||
Alpine.data(
|
||||
"camera_{{ field_name }}",
|
||||
alpine_webcam_builder(
|
||||
{{ default_picture }},
|
||||
{{ delete_url }},
|
||||
{{ (this_picture and this_picture.is_owned_by(user))|tojson }}
|
||||
)
|
||||
);
|
||||
});
|
||||
</script>
|
||||
{% endmacro %}
|
||||
|
@ -356,82 +356,6 @@ class TestUserTools:
|
||||
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):
|
||||
"""Test that the User.is_in_group() and AnonymousUser.is_in_group()
|
||||
work as intended.
|
||||
|
@ -22,7 +22,7 @@ class TestFetchFamilyApi(TestCase):
|
||||
# <- user5
|
||||
|
||||
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.godchildren.add(*cls.users[3:6])
|
||||
cls.users[1].godfathers.add(cls.users[6])
|
||||
|
221
core/tests/test_files.py
Normal file
221
core/tests/test_files.py
Normal 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
97
core/tests/test_user.py
Normal 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
|
@ -357,7 +357,7 @@ class FileDeleteView(CanEditPropMixin, DeleteView):
|
||||
|
||||
def get_success_url(self):
|
||||
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"]
|
||||
if self.object.parent is None:
|
||||
return reverse(
|
||||
|
@ -85,7 +85,8 @@ def search_user(query):
|
||||
SearchQuerySet()
|
||||
.models(User)
|
||||
.autocomplete(auto=query)
|
||||
.order_by("-last_update")[:20]
|
||||
.order_by("-last_login")
|
||||
.load_all()[:20]
|
||||
)
|
||||
return [r.object for r in res]
|
||||
except TypeError:
|
||||
|
@ -46,6 +46,7 @@ class CustomerAdmin(SearchModelAdmin):
|
||||
@admin.register(BillingInfo)
|
||||
class BillingInfoAdmin(admin.ModelAdmin):
|
||||
list_display = ("first_name", "last_name", "address_1", "city", "country")
|
||||
autocomplete_fields = ("customer",)
|
||||
|
||||
|
||||
@admin.register(Counter)
|
||||
|
@ -2,6 +2,7 @@ from ajax_select import make_ajax_field
|
||||
from ajax_select.fields import AutoCompleteSelectField, AutoCompleteSelectMultipleField
|
||||
from django import forms
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from phonenumber_field.widgets import RegionalPhoneNumberWidget
|
||||
|
||||
from core.views.forms import NFCTextInput, SelectDate, SelectDateTime
|
||||
from counter.models import (
|
||||
@ -26,7 +27,11 @@ class BillingInfoForm(forms.ModelForm):
|
||||
"zip_code",
|
||||
"city",
|
||||
"country",
|
||||
"phone_number",
|
||||
]
|
||||
widgets = {
|
||||
"phone_number": RegionalPhoneNumberWidget,
|
||||
}
|
||||
|
||||
|
||||
class StudentCardForm(forms.ModelForm):
|
||||
|
20
counter/migrations/0023_billinginfo_phone_number.py
Normal file
20
counter/migrations/0023_billinginfo_phone_number.py
Normal 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"
|
||||
),
|
||||
),
|
||||
]
|
@ -34,6 +34,7 @@ from django.utils import timezone
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django_countries.fields import CountryField
|
||||
from phonenumber_field.modelfields import PhoneNumberField
|
||||
|
||||
from accounting.models import CurrencyField
|
||||
from club.models import Club
|
||||
@ -176,6 +177,14 @@ class BillingInfo(models.Model):
|
||||
city = models.CharField(_("City"), max_length=50)
|
||||
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):
|
||||
return f"{self.first_name} {self.last_name}"
|
||||
|
||||
@ -192,6 +201,8 @@ class BillingInfo(models.Model):
|
||||
"ZipCode": self.zip_code,
|
||||
"City": self.city,
|
||||
"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:
|
||||
|
@ -315,6 +315,7 @@ class TestBillingInfo:
|
||||
"zip_code": "34301",
|
||||
"city": "Sète",
|
||||
"country": "FR",
|
||||
"phone_number": "0612345678",
|
||||
}
|
||||
|
||||
def test_edit_infos(self, client: Client, payload: dict):
|
||||
@ -356,7 +357,7 @@ class TestBillingInfo:
|
||||
for key, val in payload.items():
|
||||
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()
|
||||
client.force_login(user)
|
||||
# address_1, zip_code and country are missing
|
||||
@ -391,6 +392,60 @@ class TestBillingInfo:
|
||||
)
|
||||
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):
|
||||
@classmethod
|
||||
|
@ -1,6 +1,11 @@
|
||||
from typing import Annotated
|
||||
|
||||
from ninja import ModelSchema, Schema
|
||||
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
|
||||
|
||||
|
||||
@ -31,3 +36,8 @@ class BillingInfoSchema(ModelSchema):
|
||||
"country",
|
||||
]
|
||||
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")]
|
||||
|
@ -42,7 +42,16 @@ document.addEventListener("alpine:init", () => {
|
||||
this.req_state = res.ok
|
||||
? BillingInfoReqState.SUCCESS
|
||||
: 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();
|
||||
}
|
||||
},
|
||||
|
@ -98,13 +98,20 @@
|
||||
</form>
|
||||
</div>
|
||||
<br>
|
||||
{% if must_fill_billing_infos %}
|
||||
<p>
|
||||
<i>
|
||||
{% trans %}You must fill your billing infos if you want to pay with your credit
|
||||
card{% endtrans %}
|
||||
</i>
|
||||
</p>
|
||||
{% if billing_infos_state == BillingInfoState.EMPTY %}
|
||||
<div class="alert alert-yellow">
|
||||
{% trans %}You must fill your billing infos if you want to pay with your credit
|
||||
card{% endtrans %}
|
||||
</div>
|
||||
{% elif billing_infos_state == BillingInfoState.MISSING_PHONE_NUMBER %}
|
||||
<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 %}
|
||||
<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)">
|
||||
@ -113,7 +120,7 @@
|
||||
<input
|
||||
type="submit"
|
||||
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 %}"
|
||||
/>
|
||||
</form>
|
||||
|
@ -16,6 +16,7 @@
|
||||
import base64
|
||||
import json
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
|
||||
import sentry_sdk
|
||||
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)
|
||||
|
||||
|
||||
class BillingInfoState(Enum):
|
||||
VALID = 1
|
||||
EMPTY = 2
|
||||
MISSING_PHONE_NUMBER = 3
|
||||
|
||||
|
||||
class EbouticCommand(LoginRequiredMixin, TemplateView):
|
||||
template_name = "eboutic/eboutic_makecommand.jinja"
|
||||
basket: Basket
|
||||
@ -130,9 +137,16 @@ class EbouticCommand(LoginRequiredMixin, TemplateView):
|
||||
default_billing_info = customer.billing_infos
|
||||
else:
|
||||
kwargs["customer_amount"] = None
|
||||
kwargs["must_fill_billing_infos"] = default_billing_info is None
|
||||
if not kwargs["must_fill_billing_infos"]:
|
||||
# the user has already filled its billing_infos, thus we can
|
||||
# make the enum available in the template
|
||||
kwargs["BillingInfoState"] = BillingInfoState
|
||||
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
|
||||
kwargs["billing_infos"] = dict(self.basket.get_e_transaction_data())
|
||||
kwargs["basket"] = self.basket
|
||||
|
@ -6,7 +6,7 @@
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"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"
|
||||
"Last-Translator: Skia <skia@libskia.so>\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:203 club/models.py:54 com/models.py:274
|
||||
#: com/models.py:293 counter/models.py:209 counter/models.py:242
|
||||
#: counter/models.py:377 forum/models.py:59 launderette/models.py:29
|
||||
#: com/models.py:293 counter/models.py:213 counter/models.py:246
|
||||
#: counter/models.py:381 forum/models.py:59 launderette/models.py:29
|
||||
#: launderette/models.py:84 launderette/models.py:122 stock/models.py:36
|
||||
#: stock/models.py:57 stock/models.py:97 stock/models.py:125
|
||||
msgid "name"
|
||||
@ -66,8 +66,8 @@ msgid "account number"
|
||||
msgstr "numéro de compte"
|
||||
|
||||
#: 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
|
||||
#: counter/models.py:379 trombi/models.py:210
|
||||
#: com/models.py:74 com/models.py:259 com/models.py:299 counter/models.py:269
|
||||
#: counter/models.py:383 trombi/models.py:210
|
||||
msgid "club"
|
||||
msgstr "club"
|
||||
|
||||
@ -88,12 +88,12 @@ msgstr "Compte club"
|
||||
msgid "%(club_account)s on %(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
|
||||
msgid "start date"
|
||||
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
|
||||
msgid "end date"
|
||||
msgstr "date de fin"
|
||||
@ -106,8 +106,8 @@ msgstr "est fermé"
|
||||
msgid "club account"
|
||||
msgstr "compte club"
|
||||
|
||||
#: accounting/models.py:212 accounting/models.py:272 counter/models.py:56
|
||||
#: counter/models.py:583
|
||||
#: accounting/models.py:212 accounting/models.py:272 counter/models.py:57
|
||||
#: counter/models.py:587
|
||||
msgid "amount"
|
||||
msgstr "montant"
|
||||
|
||||
@ -127,20 +127,20 @@ msgstr "numéro"
|
||||
msgid "journal"
|
||||
msgstr "classeur"
|
||||
|
||||
#: accounting/models.py:273 core/models.py:940 core/models.py:1442
|
||||
#: core/models.py:1487 core/models.py:1516 core/models.py:1540
|
||||
#: counter/models.py:593 counter/models.py:686 counter/models.py:896
|
||||
#: accounting/models.py:273 core/models.py:940 core/models.py:1460
|
||||
#: core/models.py:1505 core/models.py:1534 core/models.py:1558
|
||||
#: counter/models.py:597 counter/models.py:690 counter/models.py:900
|
||||
#: eboutic/models.py:57 eboutic/models.py:173 forum/models.py:311
|
||||
#: forum/models.py:412 stock/models.py:96
|
||||
msgid "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
|
||||
msgid "comment"
|
||||
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
|
||||
msgid "payment method"
|
||||
msgstr "méthode de paiement"
|
||||
@ -166,8 +166,8 @@ msgid "accounting type"
|
||||
msgstr "type comptable"
|
||||
|
||||
#: accounting/models.py:311 accounting/models.py:450 accounting/models.py:483
|
||||
#: accounting/models.py:515 core/models.py:1515 core/models.py:1541
|
||||
#: counter/models.py:652
|
||||
#: accounting/models.py:515 core/models.py:1533 core/models.py:1559
|
||||
#: counter/models.py:656
|
||||
msgid "label"
|
||||
msgstr "étiquette"
|
||||
|
||||
@ -266,7 +266,7 @@ msgstr ""
|
||||
"Vous devez fournir soit un type comptable simplifié ou un type comptable "
|
||||
"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"
|
||||
msgstr "code"
|
||||
|
||||
@ -370,7 +370,7 @@ msgstr "Compte en banque : "
|
||||
#: core/templates/core/user_account_detail.jinja:66
|
||||
#: core/templates/core/user_clubs.jinja:34
|
||||
#: 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
|
||||
#: counter/templates/counter/last_ops.jinja:35
|
||||
#: 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"
|
||||
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"
|
||||
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
|
||||
msgid "End date"
|
||||
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"
|
||||
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
|
||||
#: launderette/models.py:136 launderette/models.py:198 sas/models.py:270
|
||||
#: trombi/models.py:206
|
||||
@ -1070,8 +1070,8 @@ msgstr "nom d'utilisateur"
|
||||
msgid "role"
|
||||
msgstr "rôle"
|
||||
|
||||
#: club/models.py:358 core/models.py:89 counter/models.py:210
|
||||
#: counter/models.py:243 election/models.py:13 election/models.py:115
|
||||
#: club/models.py:358 core/models.py:89 counter/models.py:214
|
||||
#: counter/models.py:247 election/models.py:13 election/models.py:115
|
||||
#: election/models.py:188 forum/models.py:60 forum/models.py:244
|
||||
msgid "description"
|
||||
msgstr "description"
|
||||
@ -1440,7 +1440,7 @@ msgstr "résumé"
|
||||
msgid "content"
|
||||
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
|
||||
#: stock/models.py:129
|
||||
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:112
|
||||
#: 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:134
|
||||
#: forum/templates/forum/macros.jinja:104
|
||||
@ -2250,7 +2250,7 @@ msgstr "avoir une notification pour chaque rechargement"
|
||||
msgid "file name"
|
||||
msgstr "nom du fichier"
|
||||
|
||||
#: core/models.py:899 core/models.py:1234
|
||||
#: core/models.py:899 core/models.py:1252
|
||||
msgid "parent"
|
||||
msgstr "parent"
|
||||
|
||||
@ -2266,11 +2266,11 @@ msgstr "miniature"
|
||||
msgid "owner"
|
||||
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"
|
||||
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"
|
||||
msgstr "groupe de vue"
|
||||
|
||||
@ -2316,11 +2316,11 @@ msgstr "Un fichier de ce nom existe déjà"
|
||||
msgid "You must provide a file"
|
||||
msgstr "Vous devez fournir un fichier"
|
||||
|
||||
#: core/models.py:1217
|
||||
#: core/models.py:1235
|
||||
msgid "page unix name"
|
||||
msgstr "nom unix de la page"
|
||||
|
||||
#: core/models.py:1223
|
||||
#: core/models.py:1241
|
||||
msgid ""
|
||||
"Enter a valid page name. This value may contain only unaccented letters, "
|
||||
"numbers and ./+/-/_ characters."
|
||||
@ -2328,55 +2328,55 @@ msgstr ""
|
||||
"Entrez un nom de page correct. Uniquement des lettres non accentuées, "
|
||||
"numéros, et ./+/-/_"
|
||||
|
||||
#: core/models.py:1241
|
||||
#: core/models.py:1259
|
||||
msgid "page name"
|
||||
msgstr "nom de la page"
|
||||
|
||||
#: core/models.py:1246
|
||||
#: core/models.py:1264
|
||||
msgid "owner group"
|
||||
msgstr "groupe propriétaire"
|
||||
|
||||
#: core/models.py:1259
|
||||
#: core/models.py:1277
|
||||
msgid "lock user"
|
||||
msgstr "utilisateur bloquant"
|
||||
|
||||
#: core/models.py:1266
|
||||
#: core/models.py:1284
|
||||
msgid "lock_timeout"
|
||||
msgstr "décompte du déblocage"
|
||||
|
||||
#: core/models.py:1316
|
||||
#: core/models.py:1334
|
||||
msgid "Duplicate page"
|
||||
msgstr "Une page de ce nom existe déjà"
|
||||
|
||||
#: core/models.py:1319
|
||||
#: core/models.py:1337
|
||||
msgid "Loop in page tree"
|
||||
msgstr "Boucle dans l'arborescence des pages"
|
||||
|
||||
#: core/models.py:1439
|
||||
#: core/models.py:1457
|
||||
msgid "revision"
|
||||
msgstr "révision"
|
||||
|
||||
#: core/models.py:1440
|
||||
#: core/models.py:1458
|
||||
msgid "page title"
|
||||
msgstr "titre de la page"
|
||||
|
||||
#: core/models.py:1441
|
||||
#: core/models.py:1459
|
||||
msgid "page content"
|
||||
msgstr "contenu de la page"
|
||||
|
||||
#: core/models.py:1482
|
||||
#: core/models.py:1500
|
||||
msgid "url"
|
||||
msgstr "url"
|
||||
|
||||
#: core/models.py:1483
|
||||
#: core/models.py:1501
|
||||
msgid "param"
|
||||
msgstr "param"
|
||||
|
||||
#: core/models.py:1488
|
||||
#: core/models.py:1506
|
||||
msgid "viewed"
|
||||
msgstr "vue"
|
||||
|
||||
#: core/models.py:1546
|
||||
#: core/models.py:1564
|
||||
msgid "operation type"
|
||||
msgstr "type d'opération"
|
||||
|
||||
@ -2474,7 +2474,7 @@ msgstr "Forum"
|
||||
msgid "Gallery"
|
||||
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
|
||||
#: eboutic/templates/eboutic/eboutic_main.jinja:4
|
||||
#: eboutic/templates/eboutic/eboutic_main.jinja:22
|
||||
@ -2693,7 +2693,7 @@ msgid "Edit group"
|
||||
msgstr "Éditer le groupe"
|
||||
|
||||
#: 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
|
||||
#: pedagogy/templates/pedagogy/uv_edit.jinja:36
|
||||
msgid "Update"
|
||||
@ -3184,39 +3184,35 @@ msgstr "Aucun cadeau donné pour l'instant"
|
||||
msgid "Edit user"
|
||||
msgstr "Éditer l'utilisateur"
|
||||
|
||||
#: core/templates/core/user_edit.jinja:36
|
||||
#: core/templates/core/user_edit.jinja:41
|
||||
msgid "Enable camera"
|
||||
msgstr "Activer la caméra"
|
||||
|
||||
#: core/templates/core/user_edit.jinja:44
|
||||
#: core/templates/core/user_edit.jinja:49
|
||||
msgid "Take a picture"
|
||||
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"
|
||||
msgstr "Pour changer votre photo de profil, demandez à un membre de l'AE"
|
||||
|
||||
#: core/templates/core/user_edit.jinja:173
|
||||
msgid "captured"
|
||||
msgstr "capturé"
|
||||
|
||||
#: core/templates/core/user_edit.jinja:196
|
||||
#: core/templates/core/user_edit.jinja:98
|
||||
msgid "Edit user profile"
|
||||
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"
|
||||
msgstr "Changer mon mot de passe"
|
||||
|
||||
#: core/templates/core/user_edit.jinja:263
|
||||
#: core/templates/core/user_edit.jinja:165
|
||||
msgid "Change user password"
|
||||
msgstr "Changer le mot de passe"
|
||||
|
||||
#: core/templates/core/user_edit.jinja:273
|
||||
#: core/templates/core/user_edit.jinja:175
|
||||
msgid "Username:"
|
||||
msgstr "Nom d'utilisateur : "
|
||||
|
||||
#: core/templates/core/user_edit.jinja:276
|
||||
#: core/templates/core/user_edit.jinja:178
|
||||
msgid "Account number:"
|
||||
msgstr "Numéro de compte : "
|
||||
|
||||
@ -3350,7 +3346,7 @@ msgstr "Achats"
|
||||
msgid "Product top 10"
|
||||
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"
|
||||
msgstr "Produit"
|
||||
|
||||
@ -3395,7 +3391,7 @@ msgstr "Cotisations"
|
||||
msgid "Subscription stats"
|
||||
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
|
||||
msgid "Counters"
|
||||
msgstr "Comptoirs"
|
||||
@ -3654,7 +3650,7 @@ msgstr "Parrain / Marraine"
|
||||
msgid "Godchild"
|
||||
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"
|
||||
msgstr "Choisir un utilisateur"
|
||||
|
||||
@ -3713,24 +3709,24 @@ msgstr "Photos"
|
||||
msgid "Galaxy"
|
||||
msgstr "Galaxie"
|
||||
|
||||
#: counter/apps.py:30 counter/models.py:403 counter/models.py:857
|
||||
#: counter/models.py:893 launderette/models.py:32 stock/models.py:39
|
||||
#: counter/apps.py:30 counter/models.py:407 counter/models.py:861
|
||||
#: counter/models.py:897 launderette/models.py:32 stock/models.py:39
|
||||
msgid "counter"
|
||||
msgstr "comptoir"
|
||||
|
||||
#: counter/forms.py:48
|
||||
#: counter/forms.py:49
|
||||
msgid "This UID is invalid"
|
||||
msgstr "Cet UID est invalide"
|
||||
|
||||
#: counter/forms.py:89
|
||||
#: counter/forms.py:90
|
||||
msgid "User not found"
|
||||
msgstr "Utilisateur non trouvé"
|
||||
|
||||
#: counter/forms.py:145
|
||||
#: counter/forms.py:146
|
||||
msgid "Parent product"
|
||||
msgstr "Produit parent"
|
||||
|
||||
#: counter/forms.py:151
|
||||
#: counter/forms.py:152
|
||||
msgid "Buying groups"
|
||||
msgstr "Groupes d'achat"
|
||||
|
||||
@ -3738,165 +3734,169 @@ msgstr "Groupes d'achat"
|
||||
msgid "Ecocup regularization"
|
||||
msgstr "Régularization des ecocups"
|
||||
|
||||
#: counter/models.py:55
|
||||
#: counter/models.py:56
|
||||
msgid "account id"
|
||||
msgstr "numéro de compte"
|
||||
|
||||
#: counter/models.py:57
|
||||
#: counter/models.py:58
|
||||
msgid "recorded product"
|
||||
msgstr "produits consignés"
|
||||
|
||||
#: counter/models.py:60
|
||||
#: counter/models.py:61
|
||||
msgid "customer"
|
||||
msgstr "client"
|
||||
|
||||
#: counter/models.py:61
|
||||
#: counter/models.py:62
|
||||
msgid "customers"
|
||||
msgstr "clients"
|
||||
|
||||
#: counter/models.py:73 counter/views.py:309
|
||||
#: counter/models.py:74 counter/views.py:309
|
||||
msgid "Not enough money"
|
||||
msgstr "Solde insuffisant"
|
||||
|
||||
#: counter/models.py:171
|
||||
#: counter/models.py:172
|
||||
msgid "First name"
|
||||
msgstr "Prénom"
|
||||
|
||||
#: counter/models.py:172
|
||||
#: counter/models.py:173
|
||||
msgid "Last name"
|
||||
msgstr "Nom de famille"
|
||||
|
||||
#: counter/models.py:173
|
||||
#: counter/models.py:174
|
||||
msgid "Address 1"
|
||||
msgstr "Adresse 1"
|
||||
|
||||
#: counter/models.py:174
|
||||
#: counter/models.py:175
|
||||
msgid "Address 2"
|
||||
msgstr "Adresse 2"
|
||||
|
||||
#: counter/models.py:175
|
||||
#: counter/models.py:176
|
||||
msgid "Zip code"
|
||||
msgstr "Code postal"
|
||||
|
||||
#: counter/models.py:176
|
||||
#: counter/models.py:177
|
||||
msgid "City"
|
||||
msgstr "Ville"
|
||||
|
||||
#: counter/models.py:177
|
||||
#: counter/models.py:178
|
||||
msgid "Country"
|
||||
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"
|
||||
msgstr "type du produit"
|
||||
|
||||
#: counter/models.py:253
|
||||
#: counter/models.py:257
|
||||
msgid "purchase price"
|
||||
msgstr "prix d'achat"
|
||||
|
||||
#: counter/models.py:254
|
||||
#: counter/models.py:258
|
||||
msgid "selling price"
|
||||
msgstr "prix de vente"
|
||||
|
||||
#: counter/models.py:255
|
||||
#: counter/models.py:259
|
||||
msgid "special selling price"
|
||||
msgstr "prix de vente spécial"
|
||||
|
||||
#: counter/models.py:262
|
||||
#: counter/models.py:266
|
||||
msgid "icon"
|
||||
msgstr "icône"
|
||||
|
||||
#: counter/models.py:267
|
||||
#: counter/models.py:271
|
||||
msgid "limit age"
|
||||
msgstr "âge limite"
|
||||
|
||||
#: counter/models.py:268
|
||||
#: counter/models.py:272
|
||||
msgid "tray price"
|
||||
msgstr "prix plateau"
|
||||
|
||||
#: counter/models.py:272
|
||||
#: counter/models.py:276
|
||||
msgid "parent product"
|
||||
msgstr "produit parent"
|
||||
|
||||
#: counter/models.py:278
|
||||
#: counter/models.py:282
|
||||
msgid "buying groups"
|
||||
msgstr "groupe d'achat"
|
||||
|
||||
#: counter/models.py:280 election/models.py:50
|
||||
#: counter/models.py:284 election/models.py:50
|
||||
msgid "archived"
|
||||
msgstr "archivé"
|
||||
|
||||
#: counter/models.py:283 counter/models.py:993
|
||||
#: counter/models.py:287 counter/models.py:997
|
||||
msgid "product"
|
||||
msgstr "produit"
|
||||
|
||||
#: counter/models.py:382
|
||||
#: counter/models.py:386
|
||||
msgid "products"
|
||||
msgstr "produits"
|
||||
|
||||
#: counter/models.py:385
|
||||
#: counter/models.py:389
|
||||
msgid "counter type"
|
||||
msgstr "type de comptoir"
|
||||
|
||||
#: counter/models.py:387
|
||||
#: counter/models.py:391
|
||||
msgid "Bar"
|
||||
msgstr "Bar"
|
||||
|
||||
#: counter/models.py:387
|
||||
#: counter/models.py:391
|
||||
msgid "Office"
|
||||
msgstr "Bureau"
|
||||
|
||||
#: counter/models.py:390
|
||||
#: counter/models.py:394
|
||||
msgid "sellers"
|
||||
msgstr "vendeurs"
|
||||
|
||||
#: counter/models.py:398 launderette/models.py:192
|
||||
#: counter/models.py:402 launderette/models.py:192
|
||||
msgid "token"
|
||||
msgstr "jeton"
|
||||
|
||||
#: counter/models.py:601
|
||||
#: counter/models.py:605
|
||||
msgid "bank"
|
||||
msgstr "banque"
|
||||
|
||||
#: counter/models.py:603 counter/models.py:693
|
||||
#: counter/models.py:607 counter/models.py:697
|
||||
msgid "is validated"
|
||||
msgstr "est validé"
|
||||
|
||||
#: counter/models.py:606
|
||||
#: counter/models.py:610
|
||||
msgid "refilling"
|
||||
msgstr "rechargement"
|
||||
|
||||
#: counter/models.py:670 eboutic/models.py:227
|
||||
#: counter/models.py:674 eboutic/models.py:227
|
||||
msgid "unit price"
|
||||
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"
|
||||
msgstr "quantité"
|
||||
|
||||
#: counter/models.py:690
|
||||
#: counter/models.py:694
|
||||
msgid "Sith account"
|
||||
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
|
||||
msgid "Credit card"
|
||||
msgstr "Carte bancaire"
|
||||
|
||||
#: counter/models.py:696
|
||||
#: counter/models.py:700
|
||||
msgid "selling"
|
||||
msgstr "vente"
|
||||
|
||||
#: counter/models.py:800
|
||||
#: counter/models.py:804
|
||||
msgid "Unknown event"
|
||||
msgstr "Événement inconnu"
|
||||
|
||||
#: counter/models.py:801
|
||||
#: counter/models.py:805
|
||||
#, python-format
|
||||
msgid "Eticket bought for the event %(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
|
||||
msgid ""
|
||||
"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 "
|
||||
"%(url)s."
|
||||
|
||||
#: counter/models.py:862
|
||||
#: counter/models.py:866
|
||||
msgid "last activity date"
|
||||
msgstr "dernière activité"
|
||||
|
||||
#: counter/models.py:865
|
||||
#: counter/models.py:869
|
||||
msgid "permanency"
|
||||
msgstr "permanence"
|
||||
|
||||
#: counter/models.py:898
|
||||
#: counter/models.py:902
|
||||
msgid "emptied"
|
||||
msgstr "coffre vidée"
|
||||
|
||||
#: counter/models.py:901
|
||||
#: counter/models.py:905
|
||||
msgid "cash register summary"
|
||||
msgstr "relevé de caisse"
|
||||
|
||||
#: counter/models.py:969
|
||||
#: counter/models.py:973
|
||||
msgid "cash summary"
|
||||
msgstr "relevé"
|
||||
|
||||
#: counter/models.py:972
|
||||
#: counter/models.py:976
|
||||
msgid "value"
|
||||
msgstr "valeur"
|
||||
|
||||
#: counter/models.py:975
|
||||
#: counter/models.py:979
|
||||
msgid "check"
|
||||
msgstr "chèque"
|
||||
|
||||
#: counter/models.py:977
|
||||
#: counter/models.py:981
|
||||
msgid "True if this is a bank check, else False"
|
||||
msgstr "Vrai si c'est un chèque, sinon Faux."
|
||||
|
||||
#: counter/models.py:981
|
||||
#: counter/models.py:985
|
||||
msgid "cash register summary item"
|
||||
msgstr "élément de relevé de caisse"
|
||||
|
||||
#: counter/models.py:997
|
||||
#: counter/models.py:1001
|
||||
msgid "banner"
|
||||
msgstr "bannière"
|
||||
|
||||
#: counter/models.py:999
|
||||
#: counter/models.py:1003
|
||||
msgid "event date"
|
||||
msgstr "date de l'événement"
|
||||
|
||||
#: counter/models.py:1001
|
||||
#: counter/models.py:1005
|
||||
msgid "event title"
|
||||
msgstr "titre de l'événement"
|
||||
|
||||
#: counter/models.py:1003
|
||||
#: counter/models.py:1007
|
||||
msgid "secret"
|
||||
msgstr "secret"
|
||||
|
||||
#: counter/models.py:1042
|
||||
#: counter/models.py:1046
|
||||
msgid "uid"
|
||||
msgstr "uid"
|
||||
|
||||
#: counter/models.py:1047
|
||||
#: counter/models.py:1051
|
||||
msgid "student cards"
|
||||
msgstr "cartes étudiante"
|
||||
|
||||
@ -4439,40 +4439,58 @@ msgstr "Solde restant : "
|
||||
msgid "Billing information"
|
||||
msgstr "Informations de facturation"
|
||||
|
||||
#: eboutic/templates/eboutic/eboutic_makecommand.jinja:104
|
||||
#: eboutic/templates/eboutic/eboutic_makecommand.jinja:103
|
||||
msgid ""
|
||||
"You must fill your billing infos if you want to pay with your credit\n"
|
||||
" card"
|
||||
" card"
|
||||
msgstr ""
|
||||
"Vous devez renseigner vos coordonnées de facturation si vous voulez payer "
|
||||
"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"
|
||||
msgstr "Payer avec une carte bancaire"
|
||||
|
||||
#: eboutic/templates/eboutic/eboutic_makecommand.jinja:122
|
||||
#: eboutic/templates/eboutic/eboutic_makecommand.jinja:129
|
||||
msgid ""
|
||||
"AE account payment disabled because your basket contains refilling items."
|
||||
msgstr ""
|
||||
"Paiement par compte AE désactivé parce que votre panier contient des bons de "
|
||||
"rechargement."
|
||||
|
||||
#: eboutic/templates/eboutic/eboutic_makecommand.jinja:124
|
||||
#: eboutic/templates/eboutic/eboutic_makecommand.jinja:131
|
||||
msgid ""
|
||||
"AE account payment disabled because you do not have enough money remaining."
|
||||
msgstr ""
|
||||
"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"
|
||||
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"
|
||||
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"
|
||||
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
|
||||
msgid "Maximum characters: %(max_length)s"
|
||||
msgstr "Nombre de caractères max: %(max_length)s"
|
||||
|
||||
#~ msgid "captured"
|
||||
#~ msgstr "capturé"
|
||||
|
@ -7,7 +7,7 @@
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"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"
|
||||
"Last-Translator: Sli <antoine@bartuccio.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-Transfer-Encoding: 8bit\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"
|
||||
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"
|
||||
msgstr "Echec de la suppression de la photo"
|
||||
|
29
poetry.lock
generated
29
poetry.lock
generated
@ -1813,6 +1813,33 @@ files = [
|
||||
[package.dependencies]
|
||||
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]]
|
||||
name = "pygments"
|
||||
version = "2.18.0"
|
||||
@ -2626,4 +2653,4 @@ filelock = ">=3.4"
|
||||
[metadata]
|
||||
lock-version = "2.0"
|
||||
python-versions = "^3.12"
|
||||
content-hash = "b6202203d272cecdb607ea8ebc1ba12dd8369e4f387f65692c4a9681915e6f48"
|
||||
content-hash = "c9c49497cc576b24c96ea914b74ef5c3a0c2981c488a599752f05aabb575f8d8"
|
||||
|
@ -45,6 +45,10 @@ dict2xml = "^1.7.3"
|
||||
Sphinx = "^5" # Needed for building xapian
|
||||
tomli = "^2.0.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]
|
||||
# deps used in prod, but unnecessary for development
|
||||
|
@ -201,7 +201,7 @@ document.addEventListener("alpine:init", () => {
|
||||
},
|
||||
|
||||
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",
|
||||
});
|
||||
if (!res.ok) {
|
||||
|
Loading…
Reference in New Issue
Block a user