Merge pull request #773 from ae-utbm/taiste

SAS, Eboutic, Antispam, psycopg
This commit is contained in:
thomas girod 2024-08-09 13:35:26 +02:00 committed by GitHub
commit f5cee10761
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
69 changed files with 1984 additions and 1637 deletions

2
.gitignore vendored
View File

@ -1,4 +1,4 @@
db.sqlite3
*.sqlite3
*.log
*.pyc
*.mo

0
antispam/__init__.py Normal file
View File

10
antispam/admin.py Normal file
View File

@ -0,0 +1,10 @@
from django.contrib import admin
from antispam.models import ToxicDomain
@admin.register(ToxicDomain)
class ToxicDomainAdmin(admin.ModelAdmin):
list_display = ("domain", "is_externally_managed", "created")
search_fields = ("domain", "is_externally_managed", "created")
list_filter = ("is_externally_managed",)

7
antispam/apps.py Normal file
View File

@ -0,0 +1,7 @@
from django.apps import AppConfig
class AntispamConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
verbose_name = "antispam"
name = "antispam"

18
antispam/forms.py Normal file
View File

@ -0,0 +1,18 @@
import re
from django import forms
from django.core.validators import EmailValidator
from django.utils.translation import gettext_lazy as _
from antispam.models import ToxicDomain
class AntiSpamEmailField(forms.EmailField):
"""An email field that email addresses with a known toxic domain."""
def run_validators(self, value: str):
super().run_validators(value)
# Domain part should exist since email validation is guaranteed to run first
domain = re.search(EmailValidator.domain_regex, value)
if ToxicDomain.objects.filter(domain=domain[0]).exists():
raise forms.ValidationError(_("Email domain is not allowed."))

View File

View File

@ -0,0 +1,69 @@
import requests
from django.conf import settings
from django.core.management import BaseCommand
from django.db.models import Max
from django.utils import timezone
from antispam.models import ToxicDomain
class Command(BaseCommand):
"""Update blocked ips/mails database"""
help = "Update blocked ips/mails database"
def add_arguments(self, parser):
parser.add_argument(
"--force", action="store_true", help="Force re-creation even if up to date"
)
def _should_update(self, *, force: bool = False) -> bool:
if force:
return True
oldest = ToxicDomain.objects.filter(is_externally_managed=True).aggregate(
res=Max("created")
)["res"]
return not (oldest and timezone.now() < (oldest + timezone.timedelta(days=1)))
def _download_domains(self, providers: list[str]) -> set[str]:
domains = set()
for provider in providers:
res = requests.get(provider)
if not res.ok:
self.stderr.write(
f"Source {provider} responded with code {res.status_code}"
)
continue
domains |= set(res.content.decode().splitlines())
return domains
def _update_domains(self, domains: set[str]):
# Cleanup database
ToxicDomain.objects.filter(is_externally_managed=True).delete()
# Create database
ToxicDomain.objects.bulk_create(
[
ToxicDomain(domain=domain, is_externally_managed=True)
for domain in domains
],
ignore_conflicts=True,
)
self.stdout.write("Domain database updated")
def handle(self, *args, **options):
if not self._should_update(force=options["force"]):
self.stdout.write("Domain database is up to date")
return
self.stdout.write("Updating domain database")
domains = self._download_domains(settings.TOXIC_DOMAINS_PROVIDERS)
if not domains:
self.stderr.write(
"No domains could be fetched from settings.TOXIC_DOMAINS_PROVIDERS. "
"Please, have a look at your settings."
)
return
self._update_domains(domains)

View File

@ -0,0 +1,35 @@
# Generated by Django 4.2.14 on 2024-08-03 23:05
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = []
operations = [
migrations.CreateModel(
name="ToxicDomain",
fields=[
(
"domain",
models.URLField(
max_length=253,
primary_key=True,
serialize=False,
verbose_name="domain",
),
),
("created", models.DateTimeField(auto_now_add=True)),
(
"is_externally_managed",
models.BooleanField(
default=False,
help_text="True if kept up-to-date using external toxic domain providers, else False",
verbose_name="is externally managed",
),
),
],
),
]

View File

19
antispam/models.py Normal file
View File

@ -0,0 +1,19 @@
from django.db import models
from django.utils.translation import gettext_lazy as _
class ToxicDomain(models.Model):
"""Domain marked as spam in public databases"""
domain = models.URLField(_("domain"), max_length=253, primary_key=True)
created = models.DateTimeField(auto_now_add=True)
is_externally_managed = models.BooleanField(
_("is externally managed"),
default=False,
help_text=_(
"True if kept up-to-date using external toxic domain providers, else False"
),
)
def __str__(self) -> str:
return self.domain

View File

@ -20,8 +20,7 @@
# Place - Suite 330, Boston, MA 02111-1307, USA.
#
#
import sys
import logging
from django.apps import AppConfig
from django.core.cache import cache
@ -41,7 +40,7 @@ class SithConfig(AppConfig):
def clear_cached_memberships(**kwargs):
Forum._club_memberships = {}
print("Connecting signals!", file=sys.stderr)
logging.getLogger("django").info("Connecting signals!")
request_started.connect(
clear_cached_memberships,
weak=False,

View File

@ -21,19 +21,12 @@ from club.models import Club
from core.models import Group, SithFile, User
from core.views.site import search_user
from counter.models import Counter, Customer, Product
def check_token(request):
return (
"counter_token" in request.session.keys()
and request.session["counter_token"]
and Counter.objects.filter(token=request.session["counter_token"]).exists()
)
from counter.utils import is_logged_in_counter
class RightManagedLookupChannel(LookupChannel):
def check_auth(self, request):
if not request.user.was_subscribed and not check_token(request):
if not request.user.was_subscribed and not is_logged_in_counter(request):
raise PermissionDenied

View File

@ -48,20 +48,24 @@ class Command(BaseCommand):
def handle(self, *args, force: bool, **options):
if not os.environ.get("VIRTUAL_ENV", None):
print("No virtual environment detected, this command can't be used")
self.stdout.write(
"No virtual environment detected, this command can't be used"
)
return
desired = self._desired_version()
if desired == self._current_version():
if not force:
print(
self.stdout.write(
f"Version {desired} is already installed, use --force to re-install"
)
return
print(f"Version {desired} is already installed, re-installing")
print(f"Installing xapian version {desired} at {os.environ['VIRTUAL_ENV']}")
self.stdout.write(f"Version {desired} is already installed, re-installing")
self.stdout.write(
f"Installing xapian version {desired} at {os.environ['VIRTUAL_ENV']}"
)
subprocess.run(
[str(Path(__file__).parent / "install_xapian.sh"), desired],
env=dict(os.environ),
).check_returncode()
print("Installation success")
self.stdout.write("Installation success")

View File

@ -35,4 +35,4 @@ class Command(BaseCommand):
root_path = settings.BASE_DIR
with open(root_path / "core/fixtures/SYNTAX.md", "r") as md:
result = markdown(md.read())
print(result, end="")
self.stdout.write(result)

View File

@ -24,6 +24,7 @@
from __future__ import annotations
import importlib
import logging
import os
import unicodedata
from datetime import date, timedelta
@ -981,7 +982,7 @@ class SithFile(models.Model):
return True
if self.is_in_sas and user.is_in_group(pk=settings.SITH_GROUP_SAS_ADMIN_ID):
return True
return user.id == self.owner.id
return user.id == self.owner_id
def can_be_viewed_by(self, user):
if hasattr(self, "profile_of"):
@ -1085,19 +1086,15 @@ class SithFile(models.Model):
# file storage
parent_path = "." + self.parent.get_full_path()
parent_full_path = settings.MEDIA_ROOT + parent_path
print("Parent full path: %s" % parent_full_path)
os.makedirs(parent_full_path, exist_ok=True)
old_path = self.file.name # Should be relative: "./users/skia/bleh.jpg"
new_path = "." + self.get_full_path()
print("Old path: %s " % old_path)
print("New path: %s " % new_path)
try:
# Make this atomic, so that a FS problem rolls back the DB change
with transaction.atomic():
# Set the new filesystem path
self.file.name = new_path
self.save()
print("New file path: %s " % self.file.path)
# Really move at the FS level
if os.path.exists(parent_full_path):
os.rename(
@ -1108,25 +1105,22 @@ class SithFile(models.Model):
# problem, and that can be solved with a simple shell
# command: `find . -type d -empty -delete`
except Exception as e:
print("This file likely had a problem. Here is the exception:")
print(repr(e))
print("-" * 80)
logging.error(e)
def _check_path_consistence(self):
file_path = str(self.file)
file_full_path = settings.MEDIA_ROOT + file_path
db_path = ".%s" % self.get_full_path()
if not os.path.exists(file_full_path):
print("%s: WARNING: real file does not exists!" % self.id)
print("file path: %s" % file_path, end="")
print(" db path: %s" % db_path)
print("%s: WARNING: real file does not exists!" % self.id) # noqa T201
print("file path: %s" % file_path, end="") # noqa T201
print(" db path: %s" % db_path) # noqa T201
return False
if file_path != db_path:
print("%s: " % self.id, end="")
print("file path: %s" % file_path, end="")
print(" db path: %s" % db_path)
print("%s: " % self.id, end="") # noqa T201
print("file path: %s" % file_path, end="") # noqa T201
print(" db path: %s" % db_path) # noqa T201
return False
print("%s OK (%s)" % (self.id, file_path))
return True
def _check_fs(self):
@ -1137,11 +1131,9 @@ class SithFile(models.Model):
else:
self._check_path_consistence()
def __getattribute__(self, attr):
if attr == "is_file":
return not self.is_folder
else:
return super().__getattribute__(attr)
@property
def is_file(self):
return not self.is_folder
@cached_property
def as_picture(self):

File diff suppressed because one or more lines are too long

View File

@ -65,3 +65,21 @@ function display_notif() {
function getCSRFToken() {
return $("[name=csrfmiddlewaretoken]").val();
}
const initialUrlParams = new URLSearchParams(window.location.search);
function update_query_string(key, value) {
const url = new URL(window.location.href);
if (!value) {
// If the value is null, undefined or empty => delete it
url.searchParams.delete(key)
} else if (Array.isArray(value)) {
url.searchParams.delete(key)
value.forEach((v) => url.searchParams.append(key, v))
} else {
url.searchParams.set(key, value);
}
history.pushState(null, document.title, url.toString());
}

View File

@ -3,6 +3,7 @@
.pagination {
text-align: center;
gap: 10px;
margin: 30px;
button {
background-color: $secondary-neutral-light-color;

View File

@ -93,6 +93,32 @@ a:not(.button) {
}
}
[aria-busy] {
--loading-size: 50px;
--loading-stroke: 5px;
--loading-duration: 1s;
position: relative;
}
[aria-busy]:after {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: var(--loading-size);
height: var(--loading-size);
margin-top: calc(var(--loading-size) / 2 * -1);
margin-left: calc(var(--loading-size) / 2 * -1);
border: var(--loading-stroke) solid rgba(0, 0, 0, .15);
border-radius: 50%;
border-top-color: rgba(0, 0, 0, 0.5);
animation: rotate calc(var(--loading-duration)) linear infinite;
}
@keyframes rotate {
100% { transform: rotate(360deg); }
}
.ib {
display: inline-block;
padding: 1px;

View File

@ -102,20 +102,10 @@ main {
border-radius: 10px;
}
.paginator {
display: flex;
justify-content: center;
gap: 10px;
width: -moz-fit-content;
width: fit-content;
background-color: rgba(0,0,0,.1);
border-radius: 10px;
padding: 10px;
margin: 10px 0 10px auto;
}
.photos,
.albums {
margin: 20px;
min-height: 50px; // To contain the aria-busy loading wheel, even if empty
box-sizing: border-box;
display: flex;
flex-direction: row;
@ -161,17 +151,13 @@ main {
> .album {
box-sizing: border-box;
background-color: #333333;
background-size: cover;
background-size: contain;
background-repeat: no-repeat;
background-position: center center;
width: calc(16 / 9 * 128px);
height: 128px;
&.vertical {
background-size: contain;
}
margin: 0;
padding: 0;
box-shadow: none;

View File

@ -27,6 +27,9 @@
{% block additional_css %}{% endblock %}
{% block additional_js %}{% endblock %}
{# Alpine JS must be loaded after scripts that use it. #}
<script src="{{ static('core/js/alpinejs.min.js') }}" defer></script>
{% endblock %}
</head>

View File

@ -9,10 +9,6 @@
{% trans user_name=profile.get_display_name() %}{{ user_name }}'s profile{% endtrans %}
{% endblock %}
{% block additional_js %}
<script src="{{ static('core/js/alpinejs.min.js') }}" defer></script>
{% endblock %}
{% block content %}
<div class="user_profile_page" x-data>
<div class="user_profile">

View File

@ -10,7 +10,6 @@
window.showSaveFilePicker = showSaveFilePicker; /* Export function to normal javascript */
</script>
<script defer type="text/javascript" src="{{ static('core/js/zipjs/zip-fs-full.min.js') }}"></script>
<script defer src="{{ static("core/js/alpinejs.min.js") }}"></script>
{% endblock %}
{% block title %}
@ -24,7 +23,7 @@
<button
:disabled="in_progress"
class="btn btn-blue"
@click="download('{{ url("api:pictures") }}?users_identified={{ object.id }}')"
@click="download_zip()"
>
<i class="fa fa-download"></i>
{% trans %}Download all my pictures{% endtrans %}
@ -87,13 +86,34 @@
Alpine.data("picture_download", () => ({
in_progress: false,
async download(url) {
/**
* @return {Promise<Picture[]>}
*/
async get_pictures() {
{# The API forbids to get more than 199 items at once
from paginated routes.
In order to download all the user pictures, it may be needed
to performs multiple requests #}
const max_per_page = 1;
const url = "{{ url("api:pictures") }}"
+ "?users_identified={{ object.id }}"
+ `&page_size=${max_per_page}`;
let promises = [];
const nb_pages = Math.ceil({{ nb_pictures }} / max_per_page);
for (let i = 1; i <= nb_pages; i++) {
promises.push(
fetch(url + `&page=${i}`).then(res => res.json().then(json => json.results))
);
}
return (await Promise.all(promises)).flat()
},
async download_zip(){
this.in_progress = true;
const bar = this.$refs.progress;
bar.value = 0;
/** @type Picture[] */
const pictures = await (await fetch(url)).json();
const pictures = await this.get_pictures();
bar.max = pictures.length;
const fileHandle = await window.showSaveFilePicker({

View File

@ -24,8 +24,10 @@ from django.core.mail import EmailMessage
from django.test import Client, TestCase
from django.urls import reverse
from django.utils.timezone import now
from model_bakery import baker
from pytest_django.asserts import assertInHTML, assertRedirects
from antispam.models import ToxicDomain
from club.models import Membership
from core.markdown import markdown
from core.models import AnonymousUser, Group, Page, User
@ -48,6 +50,10 @@ class TestUserRegistration:
"captcha_1": "PASSED",
}
@pytest.fixture()
def scam_domains(self):
return [baker.make(ToxicDomain, domain="scammer.spam")]
def test_register_user_form_ok(self, client, valid_payload):
"""Should register a user correctly."""
assert not User.objects.filter(email=valid_payload["email"]).exists()
@ -64,14 +70,25 @@ class TestUserRegistration:
{"password2": "not the same as password1"},
"Les deux mots de passe ne correspondent pas.",
),
({"email": "not-an-email"}, "Saisissez une adresse de courriel valide."),
(
{"email": "not-an-email"},
"Saisissez une adresse de courriel valide.",
),
(
{"email": "not\\an@email.com"},
"Saisissez une adresse de courriel valide.",
),
(
{"email": "legit@scammer.spam"},
"Le domaine de l'addresse e-mail n'est pas autorisé.",
),
({"first_name": ""}, "Ce champ est obligatoire."),
({"last_name": ""}, "Ce champ est obligatoire."),
({"captcha_1": "WRONG_CAPTCHA"}, "CAPTCHA invalide"),
],
)
def test_register_user_form_fail(
self, client, valid_payload, payload_edit, expected_error
self, client, scam_domains, valid_payload, payload_edit, expected_error
):
"""Should not register a user correctly."""
payload = valid_payload | payload_edit

View File

@ -96,10 +96,7 @@ def get_semester_code(d: Optional[date] = None) -> str:
def scale_dimension(width, height, long_edge):
if width > height:
ratio = long_edge * 1.0 / width
else:
ratio = long_edge * 1.0 / height
ratio = long_edge / max(width, height)
return int(width * ratio), int(height * ratio)
@ -107,8 +104,8 @@ def resize_image(im, edge, img_format):
(w, h) = im.size
(width, height) = scale_dimension(w, h, long_edge=edge)
content = BytesIO()
# use the lanczos filter for antialiasing
im = im.resize((width, height), Resampling.LANCZOS)
# use the lanczos filter for antialiasing and discard the alpha channel
im = im.resize((width, height), Resampling.LANCZOS).convert("RGB")
try:
im.save(
fp=content,

View File

@ -12,6 +12,7 @@
# OR WITHIN THE LOCAL FILE "LICENSE"
#
#
from urllib.parse import quote, urljoin
# This file contains all the views that concern the page model
from wsgiref.util import FileWrapper
@ -21,7 +22,7 @@ from django import forms
from django.conf import settings
from django.core.exceptions import PermissionDenied
from django.forms.models import modelform_factory
from django.http import Http404, HttpResponse
from django.http import Http404, HttpRequest, HttpResponse
from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse
from django.utils.http import http_date
@ -37,33 +38,42 @@ from core.views import (
CanViewMixin,
can_view,
)
from counter.models import Counter
from counter.utils import is_logged_in_counter
def send_file(request, file_id, file_class=SithFile, file_attr="file"):
"""Send a file through Django without loading the whole file into
memory at once. The FileWrapper will turn the file object into an
iterator for chunks of 8KB.
def send_file(
request: HttpRequest,
file_id: int,
file_class: type[SithFile] = SithFile,
file_attr: str = "file",
) -> HttpResponse:
"""Send a protected file, if the user can see it.
In prod, the server won't handle the download itself,
but set the appropriate headers in the response to make the reverse-proxy
deal with it.
In debug mode, the server will directly send the file.
"""
f = get_object_or_404(file_class, id=file_id)
if not (
can_view(f, request.user)
or (
"counter_token" in request.session.keys()
and request.session["counter_token"]
and Counter.objects.filter( # check if not null for counters that have no token set
token=request.session["counter_token"]
).exists()
)
):
if not can_view(f, request.user) and not is_logged_in_counter(request):
raise PermissionDenied
name = f.__getattribute__(file_attr).name
name = getattr(f, file_attr).name
filepath = settings.MEDIA_ROOT / name
# check if file exists on disk
if not filepath.exists():
raise Http404
if not settings.DEBUG:
# When receiving a response with the Accel-Redirect header,
# the reverse proxy will automatically handle the file sending.
# This is really hard to test (thus isn't tested)
# so please do not mess with this.
response = HttpResponse(status=200)
response["Content-Type"] = ""
response["X-Accel-Redirect"] = quote(urljoin(settings.MEDIA_URL, name))
return response
with open(filepath, "rb") as filename:
wrapper = FileWrapper(filename)
response = HttpResponse(wrapper, content_type=f.mime_type)

View File

@ -16,7 +16,7 @@
# details.
#
# You should have received a copy of the GNU General Public License along with
# this program; if not, write to the Free Sofware Foundation, Inc., 59 Temple
# this program; if not, write to the Free Software Foundation, Inc., 59 Temple
# Place - Suite 330, Boston, MA 02111-1307, USA.
#
#
@ -45,6 +45,7 @@ from django.utils.translation import gettext_lazy as _
from phonenumber_field.widgets import RegionalPhoneNumberWidget
from PIL import Image
from antispam.forms import AntiSpamEmailField
from core.models import Gift, Page, SithFile, User
from core.utils import resize_image
@ -194,6 +195,9 @@ class RegisteringForm(UserCreationForm):
class Meta:
model = User
fields = ("first_name", "last_name", "email")
field_classes = {
"email": AntiSpamEmailField,
}
class UserProfileForm(forms.ModelForm):

View File

@ -22,6 +22,7 @@
#
#
import itertools
import logging
# This file contains all the views that concern the user model
from datetime import date, timedelta
@ -318,6 +319,7 @@ class UserPicturesView(UserTabsMixin, CanViewMixin, DetailView):
.order_by("-parent__date", "-date")
.annotate(album=F("parent__name"))
)
kwargs["nb_pictures"] = len(pictures)
kwargs["albums"] = {
album: list(picts)
for album, picts in itertools.groupby(pictures, lambda i: i.album)
@ -801,7 +803,7 @@ class UserAccountView(UserAccountBase):
product__eticket=None
).all()
except Exception as e:
print(repr(e))
logging.error(e)
return kwargs

View File

@ -310,11 +310,26 @@ class Product(models.Model):
Returns:
True if the user can buy this product else False
Warnings:
This performs a db query, thus you can quickly have
a N+1 queries problem if you call it in a loop.
Hopefully, you can avoid that if you prefetch the buying_groups :
```python
user = User.objects.get(username="foobar")
products = [
p
for p in Product.objects.prefetch_related("buying_groups")
if p.can_be_sold_to(user)
]
```
"""
if not self.buying_groups.exists():
buying_groups = list(self.buying_groups.all())
if not buying_groups:
return True
for group_id in self.buying_groups.values_list("pk", flat=True):
if user.is_in_group(pk=group_id):
for group in buying_groups:
if user.is_in_group(pk=group.id):
return True
return False
@ -690,14 +705,14 @@ class Selling(models.Model):
self.customer.amount -= self.quantity * self.unit_price
self.customer.save(allow_negative=allow_negative, is_selling=True)
self.is_validated = True
u = User.objects.filter(id=self.customer.user.id).first()
if u.was_subscribed:
user = self.customer.user
if user.was_subscribed:
if (
self.product
and self.product.id == settings.SITH_PRODUCT_SUBSCRIPTION_ONE_SEMESTER
):
sub = Subscription(
member=u,
member=user,
subscription_type="un-semestre",
payment_method="EBOUTIC",
location="EBOUTIC",
@ -719,9 +734,8 @@ class Selling(models.Model):
self.product
and self.product.id == settings.SITH_PRODUCT_SUBSCRIPTION_TWO_SEMESTERS
):
u = User.objects.filter(id=self.customer.user.id).first()
sub = Subscription(
member=u,
member=user,
subscription_type="deux-semestres",
payment_method="EBOUTIC",
location="EBOUTIC",
@ -739,13 +753,13 @@ class Selling(models.Model):
start=sub.subscription_start,
)
sub.save()
if self.customer.user.preferences.notify_on_click:
if user.preferences.notify_on_click:
Notification(
user=self.customer.user,
user=user,
url=reverse(
"core:user_account_detail",
kwargs={
"user_id": self.customer.user.id,
"user_id": user.id,
"year": self.date.year,
"month": self.date.month,
},
@ -754,19 +768,15 @@ class Selling(models.Model):
type="SELLING",
).save()
super().save(*args, **kwargs)
try:
# The product has no id until it's saved
if self.product.eticket:
self.send_mail_customer()
except:
pass
if hasattr(self.product, "eticket"):
self.send_mail_customer()
def is_owned_by(self, user):
def is_owned_by(self, user: User) -> bool:
if user.is_anonymous:
return False
return user.is_owner(self.counter) and self.payment_method != "CARD"
return self.payment_method != "CARD" and user.is_owner(self.counter)
def can_be_viewed_by(self, user):
def can_be_viewed_by(self, user: User) -> bool:
if (
not hasattr(self, "customer") or self.customer is None
): # Customer can be set to Null
@ -812,7 +822,9 @@ class Selling(models.Model):
"url": self.customer.get_full_url(),
"eticket": self.get_eticket_full_url(),
}
self.customer.user.email_user(subject, message_txt, html_message=message_html)
self.customer.user.email_user(
subject, message_txt, html_message=message_html, fail_silently=True
)
def get_eticket_full_url(self):
eticket_url = reverse("counter:eticket_pdf", kwargs={"selling_id": self.id})

View File

@ -7,7 +7,6 @@
{% block additional_js %}
<script src="{{ static('counter/js/counter_click.js') }}" defer></script>
<script src="{{ static('core/js/alpinejs.min.js') }}" defer></script>
{% endblock %}
{% block info_boxes %}

View File

@ -16,8 +16,9 @@ import json
import re
import string
import pytest
from django.core.cache import cache
from django.test import TestCase
from django.test import Client, TestCase
from django.urls import reverse
from django.utils import timezone
from django.utils.timezone import timedelta
@ -303,18 +304,11 @@ class TestCounterStats(TestCase):
]
class TestBillingInfo(TestCase):
@classmethod
def setUpTestData(cls):
cls.payload_1 = {
"first_name": "Subscribed",
"last_name": "User",
"address_1": "1 rue des Huns",
"zip_code": "90000",
"city": "Belfort",
"country": "FR",
}
cls.payload_2 = {
@pytest.mark.django_db
class TestBillingInfo:
@pytest.fixture
def payload(self):
return {
"first_name": "Subscribed",
"last_name": "User",
"address_1": "3, rue de Troyes",
@ -322,213 +316,80 @@ class TestBillingInfo(TestCase):
"city": "Sète",
"country": "FR",
}
cls.root = User.objects.get(username="root")
cls.subscriber = User.objects.get(username="subscriber")
def test_edit_infos(self):
user = self.subscriber
BillingInfo.objects.get_or_create(
customer=user.customer, defaults=self.payload_1
)
self.client.force_login(user)
response = self.client.post(
reverse("counter:edit_billing_info", args=[user.id]),
json.dumps(self.payload_2),
def test_edit_infos(self, client: Client, payload: dict):
user = subscriber_user.make()
baker.make(BillingInfo, customer=user.customer)
client.force_login(user)
response = client.put(
reverse("api:put_billing_info", args=[user.id]),
json.dumps(payload),
content_type="application/json",
)
user = User.objects.get(username="subscriber")
user.refresh_from_db()
infos = BillingInfo.objects.get(customer__user=user)
assert response.status_code == 200
self.assertJSONEqual(response.content, {"errors": None})
assert hasattr(user.customer, "billing_infos")
assert infos.customer == user.customer
assert infos.first_name == "Subscribed"
assert infos.last_name == "User"
assert infos.address_1 == "3, rue de Troyes"
assert infos.address_2 is None
assert infos.zip_code == "34301"
assert infos.city == "Sète"
assert infos.country == "FR"
for key, val in payload.items():
assert getattr(infos, key) == val
def test_create_infos_for_user_with_account(self):
user = User.objects.get(username="subscriber")
if hasattr(user.customer, "billing_infos"):
user.customer.billing_infos.delete()
self.client.force_login(user)
response = self.client.post(
reverse("counter:create_billing_info", args=[user.id]),
json.dumps(self.payload_1),
@pytest.mark.parametrize(
"user_maker", [subscriber_user.make, lambda: baker.make(User)]
)
@pytest.mark.django_db
def test_create_infos(self, client: Client, user_maker, payload):
user = user_maker()
client.force_login(user)
assert not BillingInfo.objects.filter(customer__user=user).exists()
response = client.put(
reverse("api:put_billing_info", args=[user.id]),
json.dumps(payload),
content_type="application/json",
)
user = User.objects.get(username="subscriber")
infos = BillingInfo.objects.get(customer__user=user)
assert response.status_code == 200
self.assertJSONEqual(response.content, {"errors": None})
assert hasattr(user.customer, "billing_infos")
assert infos.customer == user.customer
assert infos.first_name == "Subscribed"
assert infos.last_name == "User"
assert infos.address_1 == "1 rue des Huns"
assert infos.address_2 is None
assert infos.zip_code == "90000"
assert infos.city == "Belfort"
assert infos.country == "FR"
def test_create_infos_for_user_without_account(self):
user = User.objects.get(username="subscriber")
if hasattr(user, "customer"):
user.customer.delete()
self.client.force_login(user)
response = self.client.post(
reverse("counter:create_billing_info", args=[user.id]),
json.dumps(self.payload_1),
content_type="application/json",
)
user = User.objects.get(username="subscriber")
user.refresh_from_db()
assert hasattr(user, "customer")
assert hasattr(user.customer, "billing_infos")
assert response.status_code == 200
self.assertJSONEqual(response.content, {"errors": None})
infos = BillingInfo.objects.get(customer__user=user)
self.assertEqual(user.customer, infos.customer)
assert infos.first_name == "Subscribed"
assert infos.last_name == "User"
assert infos.address_1 == "1 rue des Huns"
assert infos.address_2 is None
assert infos.zip_code == "90000"
assert infos.city == "Belfort"
assert infos.country == "FR"
def test_create_invalid(self):
user = User.objects.get(username="subscriber")
if hasattr(user.customer, "billing_infos"):
user.customer.billing_infos.delete()
self.client.force_login(user)
# address_1, zip_code and country are missing
payload = {
"first_name": user.first_name,
"last_name": user.last_name,
"city": "Belfort",
}
response = self.client.post(
reverse("counter:create_billing_info", args=[user.id]),
json.dumps(payload),
content_type="application/json",
)
user = User.objects.get(username="subscriber")
self.assertEqual(400, response.status_code)
assert not hasattr(user.customer, "billing_infos")
expected_errors = {
"errors": [
{"field": "Adresse 1", "messages": ["Ce champ est obligatoire."]},
{"field": "Code postal", "messages": ["Ce champ est obligatoire."]},
{"field": "Country", "messages": ["Ce champ est obligatoire."]},
]
}
self.assertJSONEqual(response.content, expected_errors)
def test_edit_invalid(self):
user = User.objects.get(username="subscriber")
BillingInfo.objects.get_or_create(
customer=user.customer, defaults=self.payload_1
)
self.client.force_login(user)
# address_1, zip_code and country are missing
payload = {
"first_name": user.first_name,
"last_name": user.last_name,
"city": "Belfort",
}
response = self.client.post(
reverse("counter:edit_billing_info", args=[user.id]),
json.dumps(payload),
content_type="application/json",
)
user = User.objects.get(username="subscriber")
self.assertEqual(400, response.status_code)
assert hasattr(user.customer, "billing_infos")
expected_errors = {
"errors": [
{"field": "Adresse 1", "messages": ["Ce champ est obligatoire."]},
{"field": "Code postal", "messages": ["Ce champ est obligatoire."]},
{"field": "Country", "messages": ["Ce champ est obligatoire."]},
]
}
self.assertJSONEqual(response.content, expected_errors)
def test_edit_other_user(self):
user = User.objects.get(username="sli")
self.client.login(username="subscriber", password="plop")
BillingInfo.objects.get_or_create(
customer=user.customer, defaults=self.payload_1
)
response = self.client.post(
reverse("counter:edit_billing_info", args=[user.id]),
json.dumps(self.payload_2),
content_type="application/json",
)
self.assertEqual(403, response.status_code)
def test_edit_not_existing_infos(self):
user = User.objects.get(username="subscriber")
if hasattr(user.customer, "billing_infos"):
user.customer.billing_infos.delete()
self.client.force_login(user)
response = self.client.post(
reverse("counter:edit_billing_info", args=[user.id]),
json.dumps(self.payload_2),
content_type="application/json",
)
self.assertEqual(404, response.status_code)
def test_edit_by_root(self):
user = User.objects.get(username="subscriber")
BillingInfo.objects.get_or_create(
customer=user.customer, defaults=self.payload_1
)
self.client.force_login(self.root)
response = self.client.post(
reverse("counter:edit_billing_info", args=[user.id]),
json.dumps(self.payload_2),
content_type="application/json",
)
assert response.status_code == 200
user = User.objects.get(username="subscriber")
infos = BillingInfo.objects.get(customer__user=user)
self.assertJSONEqual(response.content, {"errors": None})
assert hasattr(user.customer, "billing_infos")
self.assertEqual(user.customer, infos.customer)
self.assertEqual("Subscribed", infos.first_name)
self.assertEqual("User", infos.last_name)
self.assertEqual("3, rue de Troyes", infos.address_1)
self.assertEqual(None, infos.address_2)
self.assertEqual("34301", infos.zip_code)
self.assertEqual("Sète", infos.city)
self.assertEqual("FR", infos.country)
def test_create_by_root(self):
user = User.objects.get(username="subscriber")
if hasattr(user.customer, "billing_infos"):
user.customer.billing_infos.delete()
self.client.force_login(self.root)
response = self.client.post(
reverse("counter:create_billing_info", args=[user.id]),
json.dumps(self.payload_2),
content_type="application/json",
)
assert response.status_code == 200
user = User.objects.get(username="subscriber")
infos = BillingInfo.objects.get(customer__user=user)
self.assertJSONEqual(response.content, {"errors": None})
assert hasattr(user.customer, "billing_infos")
assert infos.customer == user.customer
assert infos.first_name == "Subscribed"
assert infos.last_name == "User"
assert infos.address_1 == "3, rue de Troyes"
assert infos.address_2 is None
assert infos.zip_code == "34301"
assert infos.city == "Sète"
assert infos.country == "FR"
for key, val in payload.items():
assert getattr(infos, key) == val
def test_invalid_data(self, client: Client, payload):
user = subscriber_user.make()
client.force_login(user)
# address_1, zip_code and country are missing
del payload["city"]
response = client.put(
reverse("api:put_billing_info", args=[user.id]),
json.dumps(payload),
content_type="application/json",
)
assert response.status_code == 422
user.customer.refresh_from_db()
assert not hasattr(user.customer, "billing_infos")
@pytest.mark.parametrize(
("operator_maker", "expected_code"),
[
(subscriber_user.make, 403),
(lambda: baker.make(User), 403),
(lambda: baker.make(User, is_superuser=True), 200),
],
)
def test_edit_other_user(
self, client: Client, operator_maker, expected_code: int, payload: dict
):
user = subscriber_user.make()
client.force_login(operator_maker())
baker.make(BillingInfo, customer=user.customer)
response = client.put(
reverse("api:put_billing_info", args=[user.id]),
json.dumps(payload),
content_type="application/json",
)
assert response.status_code == expected_code
class TestBarmanConnection(TestCase):

View File

@ -57,16 +57,6 @@ urlpatterns = [
StudentCardDeleteView.as_view(),
name="delete_student_card",
),
path(
"customer/<int:user_id>/billing_info/create",
create_billing_info,
name="create_billing_info",
),
path(
"customer/<int:user_id>/billing_info/edit",
edit_billing_info,
name="edit_billing_info",
),
path("admin/<int:counter_id>/", CounterEditView.as_view(), name="admin"),
path(
"admin/<int:counter_id>/prop/",

35
counter/utils.py Normal file
View File

@ -0,0 +1,35 @@
from urllib.parse import urlparse
from django.http import HttpRequest
from django.urls import resolve
from counter.models import Counter
def is_logged_in_counter(request: HttpRequest) -> bool:
"""Check if the request is sent from a device logged to a counter.
The request must also be sent within the frame of a counter's activity.
Trying to use this function to manage access to non-sas
related resources probably won't work.
A request is considered as coming from a logged counter if :
- Its referer comes from the counter app
(eg. fetching user pictures from the click UI)
or the request path belongs to the counter app
(eg. the barman went back to the main by missclick and go back
to the counter)
- The current session has a counter token associated with it.
- A counter with this token exists.
"""
referer_ok = (
"HTTP_REFERER" in request.META
and resolve(urlparse(request.META["HTTP_REFERER"]).path).app_name == "counter"
)
return (
(referer_ok or request.resolver_match.app_name == "counter")
and "counter_token" in request.session
and request.session["counter_token"]
and Counter.objects.filter(token=request.session["counter_token"]).exists()
)

View File

@ -12,7 +12,6 @@
# OR WITHIN THE LOCAL FILE "LICENSE"
#
#
import json
import re
from datetime import datetime, timedelta
from datetime import timezone as tz
@ -21,7 +20,6 @@ from urllib.parse import parse_qs
from django import forms
from django.conf import settings
from django.contrib.auth.decorators import login_required
from django.core.exceptions import PermissionDenied
from django.db import DataError, transaction
from django.db.models import F
@ -56,7 +54,6 @@ from core.utils import get_semester_code, get_start_of_semester
from core.views import CanEditMixin, CanViewMixin, TabedViewMixin
from core.views.forms import LoginForm
from counter.forms import (
BillingInfoForm,
CashSummaryFormBase,
CounterEditForm,
EticketForm,
@ -67,7 +64,6 @@ from counter.forms import (
StudentCardForm,
)
from counter.models import (
BillingInfo,
CashRegisterSummary,
CashRegisterSummaryItem,
Counter,
@ -80,6 +76,7 @@ from counter.models import (
Selling,
StudentCard,
)
from counter.utils import is_logged_in_counter
class CounterAdminMixin(View):
@ -901,15 +898,9 @@ class RefillingDeleteView(DeleteView):
def dispatch(self, request, *args, **kwargs):
"""We have here a very particular right handling, we can't inherit from CanEditPropMixin."""
self.object = self.get_object()
if (
timezone.now() - self.object.date
<= timedelta(minutes=settings.SITH_LAST_OPERATIONS_LIMIT)
and "counter_token" in request.session.keys()
and request.session["counter_token"]
and Counter.objects.filter( # check if not null for counters that have no token set
token=request.session["counter_token"]
).exists()
):
if timezone.now() - self.object.date <= timedelta(
minutes=settings.SITH_LAST_OPERATIONS_LIMIT
) and is_logged_in_counter(request):
self.success_url = reverse(
"counter:details", kwargs={"counter_id": self.object.counter.id}
)
@ -932,15 +923,9 @@ class SellingDeleteView(DeleteView):
def dispatch(self, request, *args, **kwargs):
"""We have here a very particular right handling, we can't inherit from CanEditPropMixin."""
self.object = self.get_object()
if (
timezone.now() - self.object.date
<= timedelta(minutes=settings.SITH_LAST_OPERATIONS_LIMIT)
and "counter_token" in request.session.keys()
and request.session["counter_token"]
and Counter.objects.filter( # check if not null for counters that have no token set
token=request.session["counter_token"]
).exists()
):
if timezone.now() - self.object.date <= timedelta(
minutes=settings.SITH_LAST_OPERATIONS_LIMIT
) and is_logged_in_counter(request):
self.success_url = reverse(
"counter:details", kwargs={"counter_id": self.object.counter.id}
)
@ -1175,14 +1160,7 @@ class CounterLastOperationsView(CounterTabsMixin, CanViewMixin, DetailView):
def dispatch(self, request, *args, **kwargs):
"""We have here again a very particular right handling."""
self.object = self.get_object()
if (
self.object.barmen_list
and "counter_token" in request.session.keys()
and request.session["counter_token"]
and Counter.objects.filter( # check if not null for counters that have no token set
token=request.session["counter_token"]
).exists()
):
if is_logged_in_counter(request) and self.object.barmen_list:
return super().dispatch(request, *args, **kwargs)
return HttpResponseRedirect(
reverse("counter:details", kwargs={"counter_id": self.object.id})
@ -1215,14 +1193,7 @@ class CounterCashSummaryView(CounterTabsMixin, CanViewMixin, DetailView):
def dispatch(self, request, *args, **kwargs):
"""We have here again a very particular right handling."""
self.object = self.get_object()
if (
self.object.barmen_list
and "counter_token" in request.session.keys()
and request.session["counter_token"]
and Counter.objects.filter( # check if not null for counters that have no token set
token=request.session["counter_token"]
).exists()
):
if is_logged_in_counter(request) and self.object.barmen_list:
return super().dispatch(request, *args, **kwargs)
return HttpResponseRedirect(
reverse("counter:details", kwargs={"counter_id": self.object.id})
@ -1594,51 +1565,3 @@ class StudentCardFormView(FormView):
return reverse_lazy(
"core:user_prefs", kwargs={"user_id": self.customer.user.pk}
)
def __manage_billing_info_req(request, user_id, *, delete_if_fail=False):
data = json.loads(request.body)
form = BillingInfoForm(data)
if not form.is_valid():
if delete_if_fail:
Customer.objects.get(user__id=user_id).billing_infos.delete()
errors = [
{"field": str(form.fields[k].label), "messages": v}
for k, v in form.errors.items()
]
content = json.dumps({"errors": errors})
return HttpResponse(status=400, content=content)
if form.is_valid():
infos = Customer.objects.get(user__id=user_id).billing_infos
for field in form.fields:
infos.__dict__[field] = form[field].value()
infos.save()
content = json.dumps({"errors": None})
return HttpResponse(status=200, content=content)
@login_required
@require_POST
def create_billing_info(request, user_id):
user = request.user
if user.id != user_id and not user.has_perm("counter:add_billinginfo"):
raise PermissionDenied()
user = get_object_or_404(User, pk=user_id)
customer, _ = Customer.get_or_create(user)
BillingInfo.objects.create(customer=customer)
return __manage_billing_info_req(request, user_id, delete_if_fail=True)
@login_required
@require_POST
def edit_billing_info(request, user_id):
user = request.user
if user.id != user_id and not user.has_perm("counter:change_billinginfo"):
raise PermissionDenied()
user = get_object_or_404(User, pk=user_id)
if not hasattr(user, "customer"):
raise Http404
if not hasattr(user.customer, "billing_infos"):
raise Http404
return __manage_billing_info_req(request, user_id)

View File

@ -0,0 +1 @@
::: antispam.forms

View File

@ -0,0 +1 @@
::: antispam.models

View File

@ -0,0 +1,275 @@
Si le projet marche chez vous après avoir suivi les étapes
données dans la page précédente, alors vous pouvez développer.
Ce que nous nous vous avons présenté n'est absolument pas
la même configuration que celle du site, mais elle n'en
est pas moins fonctionnelle.
Cependant, vous pourriez avoir envie de faire en sorte
que votre environnement de développement soit encore plus
proche de celui en production.
Voici les étapes à suivre pour ça.
!!!tip
Configurer les dépendances du projet
peut demander beaucoup d'allers et retours entre
votre répertoire projet et divers autres emplacements.
Vous pouvez gagner du temps en déclarant un alias :
=== "bash/zsh"
```bash
alias cdp="cd /repertoire/du/projet"
```
=== "nu"
```nu
alias cdp = cd /repertoire/du/projet
```
Chaque fois qu'on vous demandera de retourner au répertoire
projet, vous aurez juste à faire :
```bash
cdp
```
## Installer les dépendances manquantes
Pour installer complètement le projet, il va falloir
quelques dépendances en plus.
Commencez par installer les dépendances système :
=== "Linux"
=== "Debian/Ubuntu"
```bash
sudo apt install postgresql redis libq-dev nginx
```
=== "Arch Linux"
```bash
sudo pacman -S postgresql redis nginx
```
=== "macOS"
```bash
brew install postgresql redis lipbq nginx
export PATH="/usr/local/opt/libpq/bin:$PATH"
source ~/.zshrc
```
Puis, installez les dépendances poetry nécessaires en prod :
```bash
poetry install --with prod
```
!!! info
Certaines dépendances peuvent être un peu longues à installer
(notamment psycopg-c).
C'est parce que ces dépendances compilent certains modules
à l'installation.
## Configurer Redis
Redis est utilisé comme cache.
Assurez-vous qu'il tourne :
```bash
sudo systemctl redis status
```
Et s'il ne tourne pas, démarrez-le :
```bash
sudo systemctl start redis
sudo systemctl enable redis # si vous voulez que redis démarre automatiquement au boot
```
Puis ajoutez le code suivant à la fin de votre fichier
`settings_custom.py` :
```python
CACHES = {
"default": {
"BACKEND": "django.core.cache.backends.redis.RedisCache",
"LOCATION": "redis://127.0.0.1:6379",
}
}
```
## Configurer PostgreSQL
PostgreSQL est utilisé comme base de données.
Passez sur le compte de l'utilisateur postgres
et lancez l'invite de commande sql :
```bash
sudo su - postgres
psql
```
Puis configurez la base de données :
```postgresql
CREATE DATABASE sith;
CREATE USER sith WITH PASSWORD 'password';
ALTER ROLE sith SET client_encoding TO 'utf8';
ALTER ROLE sith SET default_transaction_isolation TO 'read committed';
ALTER ROLE sith SET timezone TO 'UTC';
GRANT ALL PRIVILEGES ON DATABASE sith TO SITH;
\q
```
Si vous utilisez une version de PostgreSQL supérieure ou égale
à 15, vous devez exécuter une commande en plus,
en étant connecté en tant que postgres :
```bash
psql -d sith -c "GRANT ALL PRIVILEGES ON SCHEMA public to sith";
```
Puis ajoutez le code suivant à la fin de votre
`settings_custom.py` :
```python
DATABASES = {
"default": {
"ENGINE": "django.db.backends.postgresql",
"NAME": "sith",
"USER": "sith",
"PASSWORD": "password",
"HOST": "localhost",
"PORT": "", # laissez ce champ vide pour que le choix du port soit automatique
}
}
```
Enfin, créez vos données :
```bash
poetry run ./manage.py populate
```
!!! note
N'oubliez de quitter la session de l'utilisateur
postgres après avoir configuré la db.
## Configurer nginx
Nginx est utilisé comme reverse-proxy.
!!!warning
Nginx ne sert pas les fichiers de la même manière que Django.
Les fichiers statiques servis seront ceux du dossier `/static`,
tels que générés par les commandes `collectstatic` et
`compilestatic`.
Si vous changez du css ou du js sans faire tourner
ces commandes, ces changements ne seront pas reflétés.
De manière générale, utiliser nginx en dev n'est pas très utile,
voire est gênant si vous travaillez sur le front.
Ne vous embêtez pas avec ça, sauf par curiosité intellectuelle,
ou bien si vous voulez tester spécifiquement
des interactions avec le reverse proxy.
Placez-vous dans le répertoire `/etc/nginx`,
et créez les dossiers et fichiers nécessaires :
```bash
cd /etc/nginx/
sudo mkdir sites-enabled sites-available
sudo touch sites-available/sith.conf
sudo ln -s /etc/nginx/sites-available/sith.conf sites-enabled/sith.conf
```
Puis ouvrez le fichier `sites-available/sith.conf` et mettez-y le contenu suivant :
```nginx
server {
listen 8000;
server_name _;
location /static/;
root /repertoire/du/projet;
}
location ~ ^/data/(products|com|club_logos)/ {
root /repertoire/du/projet;
}
location ~ ^/data/(SAS|profiles|users|.compressed|.thumbnails)/ {
# https://nginx.org/en/docs/http/ngx_http_core_module.html#internal
internal;
root /repertoire/du/projet;
}
location / {
proxy_pass http://127.0.0.1:8001;
include uwsgi_params;
}
}
```
Ouvrez le fichier `nginx.conf`, et ajoutez la configuration suivante :
```nginx
http {
# Toute la configuration
# éventuellement déjà là
include /etc/nginx/sites-enabled/sith.conf;
}
```
Vérifiez que votre configuration est bonne :
```bash
sudo nginx -t
```
Si votre configuration n'est pas bonne, corrigez-la.
Puis lancez ou relancez nginx :
```bash
sudo systemctl restart nginx
```
Dans votre `settings_custom.py`, remplacez `DEBUG=True` par `DEBUG=False`.
Enfin, démarrez le serveur Django :
```bash
cd /repertoire/du/projet
poetry run ./manage.py runserver 8001
```
Et c'est bon, votre reverse-proxy est prêt à tourner devant votre serveur.
Nginx écoutera sur le port 8000.
Toutes les requêtes vers des fichiers statiques et les medias publiques
seront seront servies directement par nginx.
Toutes les autres requêtes seront transmises au serveur django.
## Mettre à jour la base de données antispam
L'anti spam nécessite d'être à jour par rapport à des bases de données externes.
Il existe une commande pour ça qu'il faut lancer régulièrement.
Lors de la mise en production, il est judicieux de configurer
un cron pour la mettre à jour au moins une fois par jour.
```bash
python manage.py update_spam_database
```

View File

@ -64,17 +64,19 @@ sith3/
│ └── ...
├── trombi/ (22)
│ └── ...
├── antispam/ (23)
│ └── ...
├── .coveragerc (23)
├── .envrc (24)
├── .coveragerc (24)
├── .envrc (25)
├── .gitattributes
├── .gitignore
├── .mailmap
├── .env.exemple
├── manage.py (25)
├── mkdocs.yml (26)
├── manage.py (26)
├── mkdocs.yml (27)
├── poetry.lock
├── pyproject.toml (27)
├── pyproject.toml (28)
└── README.md
```
</div>
@ -112,15 +114,16 @@ sith3/
19. Application principale du projet, contenant sa configuration.
20. Gestion des stocks des comptoirs.
21. Gestion des cotisations des utilisateurs du site.
22. Gestion des trombinoscopes.
23. Fichier de configuration de coverage.
24. Fichier de configuration de direnv.
25. Fichier généré automatiquement par Django. C'est lui
22. Fonctionalitées pour gérer le spam.
23. Gestion des trombinoscopes.
24. Fichier de configuration de coverage.
25. Fichier de configuration de direnv.
26. Fichier généré automatiquement par Django. C'est lui
qui permet d'appeler des commandes de gestion du projet
avec la syntaxe `python ./manage.py <nom de la commande>`
26. Le fichier de configuration de la documentation,
27. Le fichier de configuration de la documentation,
avec ses plugins et sa table des matières.
27. Le fichier où sont déclarés les dépendances et la configuration
28. Le fichier où sont déclarés les dépendances et la configuration
de certaines d'entre elles.

View File

@ -19,9 +19,20 @@ from eboutic.models import *
@admin.register(Basket)
class BasketAdmin(admin.ModelAdmin):
list_display = ("user", "date", "get_total")
list_display = ("user", "date", "total")
autocomplete_fields = ("user",)
def get_queryset(self, request):
return (
super()
.get_queryset(request)
.annotate(
total=Sum(
F("items__quantity") * F("items__product_unit_price"), default=0
)
)
)
@admin.register(BasketItem)
class BasketItemAdmin(admin.ModelAdmin):

38
eboutic/api.py Normal file
View File

@ -0,0 +1,38 @@
from django.shortcuts import get_object_or_404
from ninja_extra import ControllerBase, api_controller, route
from ninja_extra.exceptions import NotFound, PermissionDenied
from ninja_extra.permissions import IsAuthenticated
from pydantic import NonNegativeInt
from core.models import User
from counter.models import BillingInfo, Customer
from eboutic.models import Basket
from eboutic.schemas import BillingInfoSchema
@api_controller("/etransaction", permissions=[IsAuthenticated])
class EtransactionInfoController(ControllerBase):
@route.put("/billing-info/{user_id}", url_name="put_billing_info")
def put_user_billing_info(self, user_id: NonNegativeInt, info: BillingInfoSchema):
"""Update or create the billing info of this user."""
if user_id == self.context.request.user.id:
user = self.context.request.user
elif self.context.request.user.is_root:
user = get_object_or_404(User, pk=user_id)
else:
raise PermissionDenied
customer, _ = Customer.get_or_create(user)
BillingInfo.objects.update_or_create(
customer=customer, defaults=info.model_dump(exclude_none=True)
)
@route.get("/data", url_name="etransaction_data", include_in_schema=False)
def fetch_etransaction_data(self):
"""Generate the data to pay an eboutic command with paybox.
The data is generated with the basket that is used by the current session.
"""
basket = Basket.from_session(self.context.request.session)
if basket is None:
raise NotFound
return dict(basket.get_e_transaction_data())

View File

@ -20,17 +20,15 @@
# Place - Suite 330, Boston, MA 02111-1307, USA.
#
#
import json
import re
import typing
from functools import cached_property
from urllib.parse import unquote
from django.http import HttpRequest
from django.utils.translation import gettext as _
from sentry_sdk import capture_message
from pydantic import ValidationError
from eboutic.models import get_eboutic_products
from eboutic.schemas import PurchaseItemList, PurchaseItemSchema
class BasketForm:
@ -43,8 +41,7 @@ class BasketForm:
Thus this class is a pure standalone and performs its operations by its own means.
However, it still tries to share some similarities with a standard django Form.
Example:
-------
Examples:
::
def my_view(request):
@ -62,28 +59,13 @@ class BasketForm:
You can also use a little shortcut by directly calling `form.is_valid()`
without calling `form.clean()`. In this case, the latter method shall be
implicitly called.
"""
# check the json is an array containing non-nested objects.
# values must be strings or numbers
# this is matched :
# [{"id": 4, "name": "[PROMO 22] badges", "unit_price": 2.3, "quantity": 2}]
# but this is not :
# [{"id": {"nested_id": 10}, "name": "[PROMO 22] badges", "unit_price": 2.3, "quantity": 2}]
# and neither does this :
# [{"id": ["nested_id": 10], "name": "[PROMO 22] badges", "unit_price": 2.3, "quantity": 2}]
# and neither does that :
# [{"id": null, "name": "[PROMO 22] badges", "unit_price": 2.3, "quantity": 2}]
json_cookie_re = re.compile(
r"^\[\s*(\{\s*(\"[^\"]*\":\s*(\"[^\"]{0,64}\"|\d{0,5}\.?\d+),?\s*)*\},?\s*)*\s*\]$"
)
def __init__(self, request: HttpRequest):
self.user = request.user
self.cookies = request.COOKIES
self.error_messages = set()
self.correct_cookie = []
self.correct_items = []
def clean(self) -> None:
"""Perform all the checks, but return nothing.
@ -98,70 +80,29 @@ class BasketForm:
- all the ids refer to products the user is allowed to buy
- all the quantities are positive integers
"""
# replace escaped double quotes by single quotes, as the RegEx used to check the json
# does not support escaped double quotes
basket = unquote(self.cookies.get("basket_items", "")).replace('\\"', "'")
if basket in ("[]", ""):
self.error_messages.add(_("You have no basket."))
return
# check that the json is not nested before parsing it to make sure
# malicious user can't DDoS the server with deeply nested json
if not BasketForm.json_cookie_re.match(basket):
# As the validation of the cookie goes through a rather boring regex,
# we can regularly have to deal with subtle errors that we hadn't forecasted,
# so we explicitly lay a Sentry message capture here.
capture_message(
"Eboutic basket regex checking failed to validate basket json",
level="error",
try:
basket = PurchaseItemList.validate_json(
unquote(self.cookies.get("basket_items", "[]"))
)
except ValidationError:
self.error_messages.add(_("The request was badly formatted."))
return
try:
basket = json.loads(basket)
except json.JSONDecodeError:
self.error_messages.add(_("The basket cookie was badly formatted."))
return
if type(basket) is not list or len(basket) == 0:
if len(basket) == 0:
self.error_messages.add(_("Your basket is empty."))
return
existing_ids = {product.id for product in get_eboutic_products(self.user)}
for item in basket:
expected_keys = {"id", "quantity", "name", "unit_price"}
if type(item) is not dict or set(item.keys()) != expected_keys:
self.error_messages.add("One or more items are badly formatted.")
continue
# check the id field is a positive integer
if type(item["id"]) is not int or item["id"] < 0:
self.error_messages.add(
_("%(name)s : this product does not exist.")
% {"name": item["name"]}
)
continue
# check a product with this id does exist
ids = {product.id for product in get_eboutic_products(self.user)}
if not item["id"] in ids:
if item.product_id in existing_ids:
self.correct_items.append(item)
else:
self.error_messages.add(
_(
"%(name)s : this product does not exist or may no longer be available."
)
% {"name": item["name"]}
% {"name": item.name}
)
continue
if type(item["quantity"]) is not int or item["quantity"] < 0:
self.error_messages.add(
_("You cannot buy %(nbr)d %(name)s.")
% {"nbr": item["quantity"], "name": item["name"]}
)
continue
# if we arrive here, it means this item has passed all tests
self.correct_cookie.append(item)
# for loop for item checking ends here
# this function does not return anything.
# instead, it fills a set containing the collected error messages
# an empty set means that no error was seen thus everything is ok
@ -174,16 +115,16 @@ class BasketForm:
If the `clean()` method has not been called beforehand, call it.
"""
if self.error_messages == set() and self.correct_cookie == []:
if not self.error_messages and not self.correct_items:
self.clean()
if self.error_messages:
return False
return True
def get_error_messages(self) -> typing.List[str]:
@cached_property
def errors(self) -> list[str]:
return list(self.error_messages)
def get_cleaned_cookie(self) -> str:
if not self.correct_cookie:
return ""
return json.dumps(self.correct_cookie)
@cached_property
def cleaned_data(self) -> list[PurchaseItemSchema]:
return self.correct_items

View File

@ -16,6 +16,7 @@ from __future__ import annotations
import hmac
from datetime import datetime
from typing import Any
from dict2xml import dict2xml
from django.conf import settings
@ -38,6 +39,7 @@ def get_eboutic_products(user: User) -> list[Product]:
.annotate(priority=F("product_type__priority"))
.annotate(category=F("product_type__name"))
.annotate(category_comment=F("product_type__comment"))
.prefetch_related("buying_groups") # <-- used in `Product.can_be_sold_to`
)
return [p for p in products if p.can_be_sold_to(user)]
@ -57,66 +59,25 @@ class Basket(models.Model):
def __str__(self):
return f"{self.user}'s basket ({self.items.all().count()} items)"
def add_product(self, p: Product, q: int = 1):
"""Given p an object of the Product model and q an integer,
add q items corresponding to this Product from the basket.
If this function is called with a product not in the basket, no error will be raised
"""
item = self.items.filter(product_id=p.id).first()
if item is None:
BasketItem(
basket=self,
product_id=p.id,
product_name=p.name,
type_id=p.product_type.id,
quantity=q,
product_unit_price=p.selling_price,
).save()
else:
item.quantity += q
item.save()
def del_product(self, p: Product, q: int = 1):
"""Given p an object of the Product model and q an integer
remove q items corresponding to this Product from the basket.
If this function is called with a product not in the basket, no error will be raised
"""
try:
item = self.items.get(product_id=p.id)
except BasketItem.DoesNotExist:
return
item.quantity -= q
if item.quantity <= 0:
item.delete()
else:
item.save()
def clear(self) -> None:
"""Remove all items from this basket without deleting the basket."""
self.items.all().delete()
@cached_property
def contains_refilling_item(self) -> bool:
return self.items.filter(
type_id=settings.SITH_COUNTER_PRODUCTTYPE_REFILLING
).exists()
def get_total(self) -> float:
total = self.items.aggregate(
total=Sum(F("quantity") * F("product_unit_price"))
)["total"]
return float(total) if total is not None else 0
@cached_property
def total(self) -> float:
return float(
self.items.aggregate(
total=Sum(F("quantity") * F("product_unit_price"), default=0)
)["total"]
)
@classmethod
def from_session(cls, session) -> Basket | None:
"""The basket stored in the session object, if it exists."""
if "basket_id" in session:
try:
return cls.objects.get(id=session["basket_id"])
except cls.DoesNotExist:
return None
return cls.objects.filter(id=session["basket_id"]).first()
return None
def generate_sales(self, counter, seller: User, payment_method: str):
@ -161,18 +122,24 @@ class Basket(models.Model):
)
return sales
def get_e_transaction_data(self):
def get_e_transaction_data(self) -> list[tuple[str, Any]]:
user = self.user
if not hasattr(user, "customer"):
raise Customer.DoesNotExist
customer = user.customer
if not hasattr(user.customer, "billing_infos"):
raise BillingInfo.DoesNotExist
cart = {
"shoppingcart": {"total": {"totalQuantity": min(self.items.count(), 99)}}
}
cart = '<?xml version="1.0" encoding="UTF-8" ?>' + dict2xml(
cart, newlines=False
)
data = [
("PBX_SITE", settings.SITH_EBOUTIC_PBX_SITE),
("PBX_RANG", settings.SITH_EBOUTIC_PBX_RANG),
("PBX_IDENTIFIANT", settings.SITH_EBOUTIC_PBX_IDENTIFIANT),
("PBX_TOTAL", str(int(self.get_total() * 100))),
("PBX_TOTAL", str(int(self.total * 100))),
("PBX_DEVISE", "978"), # This is Euro
("PBX_CMD", str(self.id)),
("PBX_PORTEUR", user.email),
@ -181,14 +148,6 @@ class Basket(models.Model):
("PBX_TYPEPAIEMENT", "CARTE"),
("PBX_TYPECARTE", "CB"),
("PBX_TIME", datetime.now().replace(microsecond=0).isoformat("T")),
]
cart = {
"shoppingcart": {"total": {"totalQuantity": min(self.items.count(), 99)}}
}
cart = '<?xml version="1.0" encoding="UTF-8" ?>' + dict2xml(
cart, newlines=False
)
data += [
("PBX_SHOPPINGCART", cart),
("PBX_BILLING", customer.billing_infos.to_3dsv2_xml()),
]
@ -218,10 +177,11 @@ class Invoice(models.Model):
return f"{self.user} - {self.get_total()} - {self.date}"
def get_total(self) -> float:
total = self.items.aggregate(
total=Sum(F("quantity") * F("product_unit_price"))
)["total"]
return float(total) if total is not None else 0
return float(
self.items.aggregate(
total=Sum(F("quantity") * F("product_unit_price"), default=0)
)["total"]
)
def validate(self):
if self.validated:
@ -284,7 +244,7 @@ class BasketItem(AbstractBaseItem):
)
@classmethod
def from_product(cls, product: Product, quantity: int):
def from_product(cls, product: Product, quantity: int, basket: Basket):
"""Create a BasketItem with the same characteristics as the
product passed in parameters, with the specified quantity.
@ -293,9 +253,10 @@ class BasketItem(AbstractBaseItem):
it yourself before saving the model.
"""
return cls(
basket=basket,
product_id=product.id,
product_name=product.name,
type_id=product.product_type.id,
type_id=product.product_type_id,
quantity=quantity,
product_unit_price=product.selling_price,
)

33
eboutic/schemas.py Normal file
View File

@ -0,0 +1,33 @@
from ninja import ModelSchema, Schema
from pydantic import Field, NonNegativeInt, PositiveInt, TypeAdapter
from counter.models import BillingInfo
class PurchaseItemSchema(Schema):
product_id: NonNegativeInt = Field(alias="id")
name: str
unit_price: float
quantity: PositiveInt
# The eboutic deals with data that is dict mixed with JSON.
# Hence it would be a hassle to manage it with a proper Schema class,
# and we use a TypeAdapter instead
PurchaseItemList = TypeAdapter(list[PurchaseItemSchema])
class BillingInfoSchema(ModelSchema):
class Meta:
model = BillingInfo
fields = [
"customer",
"first_name",
"last_name",
"address_1",
"address_2",
"zip_code",
"city",
"country",
]
fields_optional = ["customer"]

View File

@ -30,22 +30,17 @@ function getCookie(name) {
*/
function get_starting_items() {
const cookie = getCookie(BASKET_ITEMS_COOKIE_NAME);
let output = [];
try {
// Django cookie backend does an utter mess on non-trivial data types
// so we must perform a conversion of our own
const biscuit = JSON.parse(cookie.replace(/\\054/g, ','));
output = Array.isArray(biscuit) ? biscuit : [];
} catch (e) {}
output.forEach(item => {
let el = document.getElementById(item.id);
el.classList.add("selected");
});
return output;
if (!cookie) {
return []
}
// Django cookie backend converts `,` to `\054`
let parsed = JSON.parse(cookie.replace(/\\054/g, ','));
if (typeof parsed === "string") {
// In some conditions, a second parsing is needed
parsed = JSON.parse(parsed);
}
const res = Array.isArray(parsed) ? parsed : [];
return res.filter((i) => !!document.getElementById(i.id))
}
document.addEventListener('alpine:init', () => {
@ -81,9 +76,6 @@ document.addEventListener('alpine:init', () => {
this.items[index].quantity -= 1;
if (this.items[index].quantity === 0) {
let el = document.getElementById(this.items[index].id);
el.classList.remove("selected");
this.items = this.items.filter((e) => e.id !== this.items[index].id);
}
this.set_cookies();
@ -93,12 +85,6 @@ document.addEventListener('alpine:init', () => {
* Remove all the items from the basket & cleans the catalog CSS classes
*/
clear_basket() {
// We remove the class "selected" from all the items in the catalog
this.items.forEach(item => {
let el = document.getElementById(item.id);
el.classList.remove("selected");
})
this.items = [];
this.set_cookies();
},
@ -108,8 +94,11 @@ document.addEventListener('alpine:init', () => {
* ! the cookie survives an hour
*/
set_cookies() {
if (this.items.length === 0) document.cookie = `${BASKET_ITEMS_COOKIE_NAME}=;Max-Age=0`;
else document.cookie = `${BASKET_ITEMS_COOKIE_NAME}=${encodeURIComponent(JSON.stringify(this.items))};Max-Age=3600`;
if (this.items.length === 0) {
document.cookie = `${BASKET_ITEMS_COOKIE_NAME}=;Max-Age=0`;
} else {
document.cookie = `${BASKET_ITEMS_COOKIE_NAME}=${encodeURIComponent(JSON.stringify(this.items))};Max-Age=3600`;
}
},
/**
@ -145,12 +134,10 @@ document.addEventListener('alpine:init', () => {
// if the item is not in the basket, we create it
// else we add + 1 to it
if (item === undefined) item = this.create_item(id, name, price);
else this.add(item);
if (item.quantity > 0) {
let el = document.getElementById(item.id);
el.classList.add("selected");
if (!item) {
item = this.create_item(id, name, price);
} else {
this.add(item);
}
},
}))

View File

@ -1,73 +1,70 @@
document.addEventListener('alpine:init', () => {
Alpine.store('bank_payment_enabled', false)
/**
* @readonly
* @enum {number}
*/
const BillingInfoReqState = {
SUCCESS: 1,
FAILURE: 2,
SENDING: 3,
};
Alpine.store('billing_inputs', {
data: JSON.parse(et_data)["data"],
document.addEventListener("alpine:init", () => {
Alpine.store("billing_inputs", {
data: et_data,
async fill() {
document.getElementById("bank-submit-button").disabled = true;
const request = new Request(et_data_url, {
method: "GET",
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
},
});
const res = await fetch(request);
const res = await fetch(et_data_url);
if (res.ok) {
const json = await res.json();
if (json["data"]) {
this.data = json["data"];
}
this.data = await res.json();
document.getElementById("bank-submit-button").disabled = false;
}
}
})
},
});
Alpine.data('billing_infos', () => ({
errors: [],
successful: false,
url: billing_info_exist ? edit_billing_info_url : create_billing_info_url,
Alpine.data("billing_infos", () => ({
/** @type {BillingInfoReqState | null} */
req_state: null,
async send_form() {
this.req_state = BillingInfoReqState.SENDING;
const form = document.getElementById("billing_info_form");
const submit_button = form.querySelector("input[type=submit]")
submit_button.disabled = true;
document.getElementById("bank-submit-button").disabled = true;
this.successful = false
let payload = {};
for (const elem of form.querySelectorAll("input")) {
if (elem.type === "text" && elem.value) {
payload[elem.name] = elem.value;
}
}
const country = form.querySelector("select");
if (country && country.value) {
payload[country.name] = country.value;
}
const request = new Request(this.url, {
method: "POST",
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
'X-CSRFToken': getCSRFToken(),
},
let payload = Object.fromEntries(
Array.from(form.querySelectorAll("input, select"))
.filter((elem) => elem.type !== "submit" && elem.value)
.map((elem) => [elem.name, elem.value]),
);
const res = await fetch(billing_info_url, {
method: "PUT",
body: JSON.stringify(payload),
});
const res = await fetch(request);
const json = await res.json();
if (json["errors"]) {
this.errors = json["errors"];
} else {
this.errors = [];
this.successful = true;
this.url = edit_billing_info_url;
this.req_state = res.ok
? BillingInfoReqState.SUCCESS
: BillingInfoReqState.FAILURE;
if (res.ok) {
Alpine.store("billing_inputs").fill();
}
submit_button.disabled = false;
}
}))
})
},
get_alert_color() {
if (this.req_state === BillingInfoReqState.SUCCESS) {
return "green";
}
if (this.req_state === BillingInfoReqState.FAILURE) {
return "red";
}
return "";
},
get_alert_message() {
if (this.req_state === BillingInfoReqState.SUCCESS) {
return billing_info_success_message;
}
if (this.req_state === BillingInfoReqState.FAILURE) {
return billing_info_failure_message;
}
return "";
},
}));
});

View File

@ -12,7 +12,6 @@
{# This script contains the code to perform requests to manipulate the
user basket without having to reload the page #}
<script src="{{ static('eboutic/js/eboutic.js') }}"></script>
<script src="{{ static('core/js/alpinejs.min.js') }}" defer></script>
{% endblock %}
{% block additional_css %}
@ -30,7 +29,6 @@
{% for error in errors %}
<p style="margin: 0">{{ error }}</p>
{% endfor %}
{% trans %}Your basket has been cleaned accordingly to those errors.{% endtrans %}
</div>
</div>
{% endif %}
@ -104,8 +102,12 @@
</div>
<div class="product-group">
{% for p in items %}
<button id="{{ p.id }}" class="product-button"
@click='add_from_catalog({{ p.id }}, {{ p.name|tojson }}, {{ p.selling_price }})'>
<button
id="{{ p.id }}"
class="product-button"
:class="{selected: items.some((i) => i.id === {{ p.id }})}"
@click='add_from_catalog({{ p.id }}, {{ p.name|tojson }}, {{ p.selling_price }})'
>
{% if p.icon %}
<img class="product-image" src="{{ p.icon.url }}"
alt="image de {{ p.name }}">

View File

@ -10,7 +10,6 @@
{% block additional_js %}
<script src="{{ static('eboutic/js/makecommand.js') }}" defer></script>
<script src="{{ static('core/js/alpinejs.min.js') }}" defer></script>
{% endblock %}
{% block content %}
@ -38,7 +37,7 @@
</table>
<p>
<strong>{% trans %}Basket amount: {% endtrans %}{{ "%0.2f"|format(basket.get_total()) }} €</strong>
<strong>{% trans %}Basket amount: {% endtrans %}{{ "%0.2f"|format(basket.total) }} €</strong>
{% if customer_amount != None %}
<br>
@ -48,49 +47,54 @@
{% if not basket.contains_refilling_item %}
<br>
{% trans %}Remaining account amount: {% endtrans %}
<strong>{{ "%0.2f"|format(customer_amount|float - basket.get_total()) }} €</strong>
<strong>{{ "%0.2f"|format(customer_amount|float - basket.total) }} €</strong>
{% endif %}
{% endif %}
</p>
<br>
{% if settings.SITH_EBOUTIC_CB_ENABLED %}
<div class="collapse" :class="{'shadow': collapsed}" x-data="{collapsed: false}" x-cloak>
<div
class="collapse"
:class="{'shadow': collapsed}"
x-data="{collapsed: !billing_info_exist}"
x-cloak
>
<div class="collapse-header clickable" @click="collapsed = !collapsed">
<span class="collapse-header-text">
{% trans %}Edit billing information{% endtrans %}
{% trans %}Billing information{% endtrans %}
</span>
<span class="collapse-header-icon" :class="{'reverse': collapsed}">
<i class="fa fa-caret-down"></i>
</span>
</div>
<form class="collapse-body" id="billing_info_form" method="post"
x-show="collapsed" x-data="billing_infos"
x-transition.scale.origin.top
@submit.prevent="send_form()">
<form
class="collapse-body"
id="billing_info_form"
x-data="billing_infos"
x-show="collapsed"
x-transition.scale.origin.top
@submit.prevent="await send_form()"
>
{% csrf_token %}
{{ billing_form }}
<br>
<br>
<div x-show="errors.length > 0" class="alert alert-red" x-transition>
<div class="alert-main">
<template x-for="error in errors">
<div x-text="error.field + ' : ' + error.messages.join(', ')"></div>
</template>
</div>
<div class="clickable" @click="errors = []">
<div
x-show="[BillingInfoReqState.SUCCESS, BillingInfoReqState.FAILURE].includes(req_state)"
class="alert"
:class="'alert-' + get_alert_color()"
x-transition
>
<div class="alert-main" x-text="get_alert_message()"></div>
<div class="clickable" @click="req_state = null">
<i class="fa fa-close"></i>
</div>
</div>
<div x-show="successful" class="alert alert-green" x-transition>
<div class="alert-main">
Informations de facturation enregistrées
</div>
<div class="clickable" @click="successful = false">
<i class="fa fa-close"></i>
</div>
</div>
<input type="submit" class="btn btn-blue clickable"
value="{% trans %}Validate{% endtrans %}">
<input
type="submit" class="btn btn-blue clickable"
value="{% trans %}Validate{% endtrans %}"
:disabled="req_state === BillingInfoReqState.SENDING"
>
</form>
</div>
<br>
@ -103,16 +107,21 @@
</p>
{% endif %}
<form method="post" action="{{ settings.SITH_EBOUTIC_ET_URL }}" name="bank-pay-form">
<template x-data x-for="input in $store.billing_inputs.data">
<input type="hidden" :name="input['key']" :value="input['value']">
<template x-data x-for="[key, value] in Object.entries($store.billing_inputs.data)">
<input type="hidden" :name="key" :value="value">
</template>
<input type="submit" id="bank-submit-button"
{% if must_fill_billing_infos %}disabled="disabled"{% endif %}
value="{% trans %}Pay with credit card{% endtrans %}"/>
<input
type="submit"
id="bank-submit-button"
{% if must_fill_billing_infos %}disabled="disabled"{% endif %}
value="{% trans %}Pay with credit card{% endtrans %}"
/>
</form>
{% endif %}
{% if basket.contains_refilling_item %}
<p>{% trans %}AE account payment disabled because your basket contains refilling items.{% endtrans %}</p>
{% elif basket.total > user.account_balance %}
<p>{% trans %}AE account payment disabled because you do not have enough money remaining.{% endtrans %}</p>
{% else %}
<form method="post" action="{{ url('eboutic:pay_with_sith') }}" name="sith-pay-form">
{% csrf_token %}
@ -125,15 +134,16 @@
{% block script %}
<script>
const create_billing_info_url = '{{ url("counter:create_billing_info", user_id=request.user.id) }}'
const edit_billing_info_url = '{{ url("counter:edit_billing_info", user_id=request.user.id) }}';
const et_data_url = '{{ url("eboutic:et_data") }}'
let billing_info_exist = {{ "true" if billing_infos else "false" }}
const billing_info_url = '{{ url("api:put_billing_info", user_id=request.user.id) }}';
const et_data_url = '{{ url("api:etransaction_data") }}';
const billing_info_exist = {{ "true" if billing_infos else "false" }};
const billing_info_success_message = "{% trans %}Billing info registration success{% endtrans %}";
const billing_info_failure_message = "{% trans %}Billing info registration failure{% endtrans %}";
{% if billing_infos %}
const et_data = {{ billing_infos|tojson }}
const et_data = {{ billing_infos|safe }}
{% else %}
const et_data = '{"data": []}'
const et_data = {}
{% endif %}
</script>
{{ super() }}

View File

@ -36,7 +36,7 @@ from django.urls import reverse
from core.models import User
from counter.models import Counter, Customer, Product, Selling
from eboutic.models import Basket
from eboutic.models import Basket, BasketItem
class TestEboutic(TestCase):
@ -60,14 +60,14 @@ class TestEboutic(TestCase):
basket = Basket.objects.create(user=user)
session["basket_id"] = basket.id
session.save()
basket.add_product(self.barbar, 3)
basket.add_product(self.cotis)
BasketItem.from_product(self.barbar, 3, basket).save()
BasketItem.from_product(self.cotis, 1, basket).save()
return basket
def generate_bank_valid_answer(self) -> str:
basket = Basket.from_session(self.client.session)
basket_id = basket.id
amount = int(basket.get_total() * 100)
amount = int(basket.total * 100)
query = f"Amount={amount}&BasketID={basket_id}&Auto=42&Error=00000"
with open("./eboutic/tests/private_key.pem", "br") as f:
PRIVKEY = f.read()
@ -88,7 +88,7 @@ class TestEboutic(TestCase):
self.subscriber.customer.amount = 100 # give money before test
self.subscriber.customer.save()
basket = self.get_busy_basket(self.subscriber)
amount = basket.get_total()
amount = basket.total
response = self.client.post(reverse("eboutic:pay_with_sith"))
self.assertRedirects(response, "/eboutic/pay/success/")
new_balance = Customer.objects.get(user=self.subscriber).amount
@ -99,7 +99,7 @@ class TestEboutic(TestCase):
def test_buy_with_sith_account_no_money(self):
self.client.force_login(self.subscriber)
basket = self.get_busy_basket(self.subscriber)
initial = basket.get_total() - 1 # just not enough to complete the sale
initial = basket.total - 1 # just not enough to complete the sale
self.subscriber.customer.amount = initial
self.subscriber.customer.save()
response = self.client.post(reverse("eboutic:pay_with_sith"))
@ -135,7 +135,7 @@ class TestEboutic(TestCase):
cotis = basket.items.filter(product_name="Cotis 2 semestres").first()
assert cotis is not None
assert cotis.quantity == 1
assert basket.get_total() == 3 * 1.7 + 28
assert basket.total == 3 * 1.7 + 28
def test_submit_empty_basket(self):
self.client.force_login(self.subscriber)
@ -151,7 +151,7 @@ class TestEboutic(TestCase):
]"""
response = self.client.get(reverse("eboutic:command"))
cookie = self.client.cookies["basket_items"].OutputString()
assert 'basket_items=""' in cookie
assert 'basket_items="[]"' in cookie
assert "Path=/eboutic" in cookie
self.assertRedirects(response, "/eboutic/")

View File

@ -16,7 +16,6 @@
import base64
import json
from datetime import datetime
from urllib.parse import unquote
import sentry_sdk
from cryptography.exceptions import InvalidSignature
@ -26,6 +25,7 @@ from cryptography.hazmat.primitives.hashes import SHA1
from cryptography.hazmat.primitives.serialization import load_pem_public_key
from django.conf import settings
from django.contrib.auth.decorators import login_required
from django.contrib.auth.mixins import LoginRequiredMixin
from django.core.exceptions import SuspiciousOperation
from django.db import DatabaseError, transaction
from django.http import HttpRequest, HttpResponse
@ -37,7 +37,14 @@ from django.views.generic import TemplateView, View
from counter.forms import BillingInfoForm
from counter.models import Counter, Customer, Product
from eboutic.forms import BasketForm
from eboutic.models import Basket, Invoice, InvoiceItem, get_eboutic_products
from eboutic.models import (
Basket,
BasketItem,
Invoice,
InvoiceItem,
get_eboutic_products,
)
from eboutic.schemas import PurchaseItemList, PurchaseItemSchema
@login_required
@ -75,43 +82,46 @@ def payment_result(request, result: str) -> HttpResponse:
return render(request, "eboutic/eboutic_payment_result.jinja", context)
class EbouticCommand(TemplateView):
class EbouticCommand(LoginRequiredMixin, TemplateView):
template_name = "eboutic/eboutic_makecommand.jinja"
basket: Basket
@method_decorator(login_required)
def post(self, request, *args, **kwargs):
return redirect("eboutic:main")
@method_decorator(login_required)
def get(self, request: HttpRequest, *args, **kwargs):
form = BasketForm(request)
if not form.is_valid():
request.session["errors"] = form.get_error_messages()
request.session["errors"] = form.errors
request.session.modified = True
res = redirect("eboutic:main")
res.set_cookie("basket_items", form.get_cleaned_cookie(), path="/eboutic")
res.set_cookie(
"basket_items",
PurchaseItemList.dump_json(form.cleaned_data, by_alias=True).decode(),
path="/eboutic",
)
return res
basket = Basket.from_session(request.session)
if basket is not None:
basket.clear()
basket.items.all().delete()
else:
basket = Basket.objects.create(user=request.user)
request.session["basket_id"] = basket.id
request.session.modified = True
items = json.loads(unquote(request.COOKIES["basket_items"]))
items.sort(key=lambda item: item["id"])
ids = [item["id"] for item in items]
quantities = [item["quantity"] for item in items]
products = Product.objects.filter(id__in=ids)
for product, qty in zip(products, quantities):
basket.add_product(product, qty)
kwargs["basket"] = basket
return self.render_to_response(self.get_context_data(**kwargs))
items: list[PurchaseItemSchema] = form.cleaned_data
pks = {item.product_id for item in items}
products = {p.pk: p for p in Product.objects.filter(pk__in=pks)}
db_items = []
for pk in pks:
quantity = sum(i.quantity for i in items if i.product_id == pk)
db_items.append(BasketItem.from_product(products[pk], quantity, basket))
BasketItem.objects.bulk_create(db_items)
self.basket = basket
return super().get(request)
def get_context_data(self, **kwargs):
# basket is already in kwargs when the method is called
default_billing_info = None
if hasattr(self.request.user, "customer"):
customer = self.request.user.customer
@ -124,9 +134,8 @@ class EbouticCommand(TemplateView):
if not kwargs["must_fill_billing_infos"]:
# the user has already filled its billing_infos, thus we can
# get it without expecting an error
data = kwargs["basket"].get_e_transaction_data()
data = {"data": [{"key": key, "value": val} for key, val in data]}
kwargs["billing_infos"] = json.dumps(data)
kwargs["billing_infos"] = dict(self.basket.get_e_transaction_data())
kwargs["basket"] = self.basket
kwargs["billing_form"] = BillingInfoForm(instance=default_billing_info)
return kwargs
@ -149,29 +158,32 @@ def pay_with_sith(request):
refilling = settings.SITH_COUNTER_PRODUCTTYPE_REFILLING
if basket is None or basket.items.filter(type_id=refilling).exists():
return redirect("eboutic:main")
c = Customer.objects.filter(user__id=basket.user.id).first()
c = Customer.objects.filter(user__id=basket.user_id).first()
if c is None:
return redirect("eboutic:main")
if c.amount < basket.get_total():
if c.amount < basket.total:
res = redirect("eboutic:payment_result", "failure")
res.delete_cookie("basket_items", "/eboutic")
return res
eboutic = Counter.objects.get(type="EBOUTIC")
sales = basket.generate_sales(eboutic, c.user, "SITH_ACCOUNT")
try:
with transaction.atomic():
# Selling.save has some important business logic in it.
# Do not bulk_create this
for sale in sales:
sale.save()
basket.delete()
request.session.pop("basket_id", None)
res = redirect("eboutic:payment_result", "success")
except DatabaseError as e:
with sentry_sdk.push_scope() as scope:
scope.user = {"username": request.user.username}
scope.set_extra("someVariable", e.__repr__())
sentry_sdk.capture_message(
f"Erreur le {datetime.now()} dans eboutic.pay_with_sith"
)
res = redirect("eboutic:payment_result", "failure")
else:
eboutic = Counter.objects.filter(type="EBOUTIC").first()
sales = basket.generate_sales(eboutic, c.user, "SITH_ACCOUNT")
try:
with transaction.atomic():
for sale in sales:
sale.save()
basket.delete()
request.session.pop("basket_id", None)
res = redirect("eboutic:payment_result", "success")
except DatabaseError as e:
with sentry_sdk.push_scope() as scope:
scope.user = {"username": request.user.username}
scope.set_extra("someVariable", e.__repr__())
sentry_sdk.capture_message(
f"Erreur le {datetime.now()} dans eboutic.pay_with_sith"
)
res = redirect("eboutic:payment_result", "failure")
res.delete_cookie("basket_items", "/eboutic")
return res
@ -205,7 +217,7 @@ class EtransactionAutoAnswer(View):
)
if b is None:
raise SuspiciousOperation("Basket does not exists")
if int(b.get_total() * 100) != int(request.GET["Amount"]):
if int(b.total * 100) != int(request.GET["Amount"]):
raise SuspiciousOperation(
"Basket total and amount do not match"
)

View File

@ -21,6 +21,7 @@
# Place - Suite 330, Boston, MA 02111-1307, USA.
#
#
import logging
import math
from functools import partial
@ -424,7 +425,7 @@ class ForumMessageCreateView(CanCreateMixin, CreateView):
)
init["message"] += "\n\n"
except Exception as e:
print(repr(e))
logging.error(e)
return init
def form_valid(self, form):

File diff suppressed because it is too large Load Diff

View File

@ -61,6 +61,7 @@ nav:
- Archives: explanation/archives.md
- Tutoriels:
- Installer le projet: tutorial/install.md
- Installer le projet (avancé): tutorial/install-advanced.md
- Configurer son éditeur: tutorial/devtools.md
- Structure du projet: tutorial/structure.md
- Gestion des permissions: tutorial/perms.md
@ -80,6 +81,9 @@ nav:
- accounting:
- reference/accounting/models.md
- reference/accounting/views.md
- antispam:
- reference/antispam/models.md
- reference/antispam/forms.md
- club:
- reference/club/models.md
- reference/club/views.md

View File

@ -36,11 +36,7 @@ def remove_multiples_comments_from_same_user(apps, schema_editor):
.order_by("-publish_date")
.first()
)
for comment in (
user.uv_comments.filter(uv__id=uv["uv"]).exclude(pk=last.pk).all()
):
print("removing : %s" % (comment,))
comment.delete()
user.uv_comments.filter(uv__id=uv["uv"]).exclude(pk=last.pk).delete()
class Migration(migrations.Migration):

View File

@ -4,10 +4,6 @@
{% trans %}UV Guide{% endtrans %}
{% endblock %}
{% block additional_js %}
<script src="{{ static('core/js/alpinejs.min.js') }}" defer></script>
{% endblock %}
{% block additional_css %}
<link rel="stylesheet" href="{{ scss('pedagogy/css/pedagogy.scss') }}">
<link rel="stylesheet" href="{{ scss('core/pagination.scss') }}">
@ -100,7 +96,7 @@
{% endif %}
</tr>
</thead>
<tbody id="dynamic_view_content">
<tbody id="dynamic_view_content" :aria-busy="loading">
<template x-for="uv in uvs.results" :key="uv.id">
<tr @click="window.location.href = `/pedagogy/uv/${uv.id}`" class="clickable">
<td><a :href="`/pedagogy/uv/${uv.id}`" x-text="uv.code"></a></td>
@ -130,22 +126,6 @@
</nav>
</div>
<script>
const initialUrlParams = new URLSearchParams(window.location.search);
function update_query_string(key, value) {
const url = new URL(window.location.href);
if (!value) {
{# If the value is null, undefined or empty => delete it #}
url.searchParams.delete(key)
} else if (Array.isArray(value)) {
url.searchParams.delete(key)
value.forEach((v) => url.searchParams.append(key, v))
} else {
url.searchParams.set(key, value);
}
history.pushState(null, document.title, url.toString());
}
{#
How does this work :
@ -160,6 +140,7 @@
document.addEventListener("alpine:init", () => {
Alpine.data("uv_search", () => ({
uvs: [],
loading: false,
page: parseInt(initialUrlParams.get("page")) || page_default,
page_size: parseInt(initialUrlParams.get("page_size")) || page_size_default,
search: initialUrlParams.get("search") || "",
@ -191,8 +172,10 @@
},
async fetch_data() {
this.loading = true;
const url = "{{ url("api:fetch_uvs") }}" + window.location.search;
this.uvs = await (await fetch(url)).json();
this.loading = false;
},
max_page() {

135
poetry.lock generated
View File

@ -574,17 +574,17 @@ testing = ["coverage", "geopy (==2)", "pysolr (>=3.7)", "python-dateutil", "requ
[[package]]
name = "django-honeypot"
version = "1.2.0"
version = "1.2.1"
description = "Django honeypot field utilities"
optional = false
python-versions = "<4.0,>=3.8"
files = [
{file = "django_honeypot-1.2.0-py3-none-any.whl", hash = "sha256:53dd5f8dd96ef1bb7e31b5514c0dc2caae9577e78ebdf03ca4e0f304a7422aba"},
{file = "django_honeypot-1.2.0.tar.gz", hash = "sha256:25fca02e786aec26649bd13b37a95c846e09ab3cfc10f28db2f7dfaa77b9b9c6"},
{file = "django_honeypot-1.2.1-py3-none-any.whl", hash = "sha256:fdabe4ded66b6db25d04af2446de3bf7cb047cb5db097a864c8c97b081ef736f"},
{file = "django_honeypot-1.2.1.tar.gz", hash = "sha256:ab5c2aad214d86def2f00f6a79aa14f171db7301ac8712f20dc21a83dd5d6413"},
]
[package.dependencies]
Django = ">=3.2,<5.1"
Django = ">=3.2,<5.2"
[[package]]
name = "django-jinja"
@ -754,13 +754,13 @@ tests = ["asttokens (>=2.1.0)", "coverage", "coverage-enable-subprocess", "ipyth
[[package]]
name = "faker"
version = "26.0.0"
version = "26.1.0"
description = "Faker is a Python package that generates fake data for you."
optional = false
python-versions = ">=3.8"
files = [
{file = "Faker-26.0.0-py3-none-any.whl", hash = "sha256:886ee28219be96949cd21ecc96c4c742ee1680e77f687b095202c8def1a08f06"},
{file = "Faker-26.0.0.tar.gz", hash = "sha256:0f60978314973de02c00474c2ae899785a42b2cf4f41b7987e93c132a2b8a4a9"},
{file = "Faker-26.1.0-py3-none-any.whl", hash = "sha256:e8c5ef795223e945d9166aea3c0ecaf85ac54b4ade2af068d8e3c6524c2c0aa7"},
{file = "Faker-26.1.0.tar.gz", hash = "sha256:33921b6fc3b83dd75fd42ec7f47ec87b50c00d3c5380fa7d8a507dab848b8229"},
]
[package.dependencies]
@ -1454,13 +1454,13 @@ ptyprocess = ">=0.5"
[[package]]
name = "phonenumbers"
version = "8.13.40"
version = "8.13.42"
description = "Python version of Google's common library for parsing, formatting, storing and validating international phone numbers."
optional = false
python-versions = "*"
files = [
{file = "phonenumbers-8.13.40-py2.py3-none-any.whl", hash = "sha256:9582752c20a1da5ec4449f7f97542bf8a793c8e2fec0ab57f767177bb8fc0b1d"},
{file = "phonenumbers-8.13.40.tar.gz", hash = "sha256:f137c2848b8e83dd064b71881b65680584417efa202177fd330e2f7ff6c68113"},
{file = "phonenumbers-8.13.42-py2.py3-none-any.whl", hash = "sha256:18acc22ee03116d27b26e990f53806a1770a3e05f05e1620bc09ad187f889456"},
{file = "phonenumbers-8.13.42.tar.gz", hash = "sha256:7137904f2db3b991701e853174ce8e1cb8f540b8bfdf27617540de04c0b7bed5"},
]
[[package]]
@ -1624,84 +1624,37 @@ files = [
wcwidth = "*"
[[package]]
name = "psycopg2-binary"
version = "2.9.9"
description = "psycopg2 - Python-PostgreSQL Database Adapter"
name = "psycopg"
version = "3.2.1"
description = "PostgreSQL database adapter for Python"
optional = false
python-versions = ">=3.7"
python-versions = ">=3.8"
files = [
{file = "psycopg2-binary-2.9.9.tar.gz", hash = "sha256:7f01846810177d829c7692f1f5ada8096762d9172af1b1a28d4ab5b77c923c1c"},
{file = "psycopg2_binary-2.9.9-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c2470da5418b76232f02a2fcd2229537bb2d5a7096674ce61859c3229f2eb202"},
{file = "psycopg2_binary-2.9.9-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c6af2a6d4b7ee9615cbb162b0738f6e1fd1f5c3eda7e5da17861eacf4c717ea7"},
{file = "psycopg2_binary-2.9.9-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:75723c3c0fbbf34350b46a3199eb50638ab22a0228f93fb472ef4d9becc2382b"},
{file = "psycopg2_binary-2.9.9-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:83791a65b51ad6ee6cf0845634859d69a038ea9b03d7b26e703f94c7e93dbcf9"},
{file = "psycopg2_binary-2.9.9-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0ef4854e82c09e84cc63084a9e4ccd6d9b154f1dbdd283efb92ecd0b5e2b8c84"},
{file = "psycopg2_binary-2.9.9-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ed1184ab8f113e8d660ce49a56390ca181f2981066acc27cf637d5c1e10ce46e"},
{file = "psycopg2_binary-2.9.9-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:d2997c458c690ec2bc6b0b7ecbafd02b029b7b4283078d3b32a852a7ce3ddd98"},
{file = "psycopg2_binary-2.9.9-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:b58b4710c7f4161b5e9dcbe73bb7c62d65670a87df7bcce9e1faaad43e715245"},
{file = "psycopg2_binary-2.9.9-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:0c009475ee389757e6e34611d75f6e4f05f0cf5ebb76c6037508318e1a1e0d7e"},
{file = "psycopg2_binary-2.9.9-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:8dbf6d1bc73f1d04ec1734bae3b4fb0ee3cb2a493d35ede9badbeb901fb40f6f"},
{file = "psycopg2_binary-2.9.9-cp310-cp310-win32.whl", hash = "sha256:3f78fd71c4f43a13d342be74ebbc0666fe1f555b8837eb113cb7416856c79682"},
{file = "psycopg2_binary-2.9.9-cp310-cp310-win_amd64.whl", hash = "sha256:876801744b0dee379e4e3c38b76fc89f88834bb15bf92ee07d94acd06ec890a0"},
{file = "psycopg2_binary-2.9.9-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ee825e70b1a209475622f7f7b776785bd68f34af6e7a46e2e42f27b659b5bc26"},
{file = "psycopg2_binary-2.9.9-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1ea665f8ce695bcc37a90ee52de7a7980be5161375d42a0b6c6abedbf0d81f0f"},
{file = "psycopg2_binary-2.9.9-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:143072318f793f53819048fdfe30c321890af0c3ec7cb1dfc9cc87aa88241de2"},
{file = "psycopg2_binary-2.9.9-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c332c8d69fb64979ebf76613c66b985414927a40f8defa16cf1bc028b7b0a7b0"},
{file = "psycopg2_binary-2.9.9-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f7fc5a5acafb7d6ccca13bfa8c90f8c51f13d8fb87d95656d3950f0158d3ce53"},
{file = "psycopg2_binary-2.9.9-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:977646e05232579d2e7b9c59e21dbe5261f403a88417f6a6512e70d3f8a046be"},
{file = "psycopg2_binary-2.9.9-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:b6356793b84728d9d50ead16ab43c187673831e9d4019013f1402c41b1db9b27"},
{file = "psycopg2_binary-2.9.9-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:bc7bb56d04601d443f24094e9e31ae6deec9ccb23581f75343feebaf30423359"},
{file = "psycopg2_binary-2.9.9-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:77853062a2c45be16fd6b8d6de2a99278ee1d985a7bd8b103e97e41c034006d2"},
{file = "psycopg2_binary-2.9.9-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:78151aa3ec21dccd5cdef6c74c3e73386dcdfaf19bced944169697d7ac7482fc"},
{file = "psycopg2_binary-2.9.9-cp311-cp311-win32.whl", hash = "sha256:dc4926288b2a3e9fd7b50dc6a1909a13bbdadfc67d93f3374d984e56f885579d"},
{file = "psycopg2_binary-2.9.9-cp311-cp311-win_amd64.whl", hash = "sha256:b76bedd166805480ab069612119ea636f5ab8f8771e640ae103e05a4aae3e417"},
{file = "psycopg2_binary-2.9.9-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:8532fd6e6e2dc57bcb3bc90b079c60de896d2128c5d9d6f24a63875a95a088cf"},
{file = "psycopg2_binary-2.9.9-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b0605eaed3eb239e87df0d5e3c6489daae3f7388d455d0c0b4df899519c6a38d"},
{file = "psycopg2_binary-2.9.9-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f8544b092a29a6ddd72f3556a9fcf249ec412e10ad28be6a0c0d948924f2212"},
{file = "psycopg2_binary-2.9.9-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2d423c8d8a3c82d08fe8af900ad5b613ce3632a1249fd6a223941d0735fce493"},
{file = "psycopg2_binary-2.9.9-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2e5afae772c00980525f6d6ecf7cbca55676296b580c0e6abb407f15f3706996"},
{file = "psycopg2_binary-2.9.9-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6e6f98446430fdf41bd36d4faa6cb409f5140c1c2cf58ce0bbdaf16af7d3f119"},
{file = "psycopg2_binary-2.9.9-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c77e3d1862452565875eb31bdb45ac62502feabbd53429fdc39a1cc341d681ba"},
{file = "psycopg2_binary-2.9.9-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:cb16c65dcb648d0a43a2521f2f0a2300f40639f6f8c1ecbc662141e4e3e1ee07"},
{file = "psycopg2_binary-2.9.9-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:911dda9c487075abd54e644ccdf5e5c16773470a6a5d3826fda76699410066fb"},
{file = "psycopg2_binary-2.9.9-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:57fede879f08d23c85140a360c6a77709113efd1c993923c59fde17aa27599fe"},
{file = "psycopg2_binary-2.9.9-cp312-cp312-win32.whl", hash = "sha256:64cf30263844fa208851ebb13b0732ce674d8ec6a0c86a4e160495d299ba3c93"},
{file = "psycopg2_binary-2.9.9-cp312-cp312-win_amd64.whl", hash = "sha256:81ff62668af011f9a48787564ab7eded4e9fb17a4a6a74af5ffa6a457400d2ab"},
{file = "psycopg2_binary-2.9.9-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:2293b001e319ab0d869d660a704942c9e2cce19745262a8aba2115ef41a0a42a"},
{file = "psycopg2_binary-2.9.9-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:03ef7df18daf2c4c07e2695e8cfd5ee7f748a1d54d802330985a78d2a5a6dca9"},
{file = "psycopg2_binary-2.9.9-cp37-cp37m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a602ea5aff39bb9fac6308e9c9d82b9a35c2bf288e184a816002c9fae930b77"},
{file = "psycopg2_binary-2.9.9-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8359bf4791968c5a78c56103702000105501adb557f3cf772b2c207284273984"},
{file = "psycopg2_binary-2.9.9-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:275ff571376626195ab95a746e6a04c7df8ea34638b99fc11160de91f2fef503"},
{file = "psycopg2_binary-2.9.9-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:f9b5571d33660d5009a8b3c25dc1db560206e2d2f89d3df1cb32d72c0d117d52"},
{file = "psycopg2_binary-2.9.9-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:420f9bbf47a02616e8554e825208cb947969451978dceb77f95ad09c37791dae"},
{file = "psycopg2_binary-2.9.9-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:4154ad09dac630a0f13f37b583eae260c6aa885d67dfbccb5b02c33f31a6d420"},
{file = "psycopg2_binary-2.9.9-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:a148c5d507bb9b4f2030a2025c545fccb0e1ef317393eaba42e7eabd28eb6041"},
{file = "psycopg2_binary-2.9.9-cp37-cp37m-win32.whl", hash = "sha256:68fc1f1ba168724771e38bee37d940d2865cb0f562380a1fb1ffb428b75cb692"},
{file = "psycopg2_binary-2.9.9-cp37-cp37m-win_amd64.whl", hash = "sha256:281309265596e388ef483250db3640e5f414168c5a67e9c665cafce9492eda2f"},
{file = "psycopg2_binary-2.9.9-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:60989127da422b74a04345096c10d416c2b41bd7bf2a380eb541059e4e999980"},
{file = "psycopg2_binary-2.9.9-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:246b123cc54bb5361588acc54218c8c9fb73068bf227a4a531d8ed56fa3ca7d6"},
{file = "psycopg2_binary-2.9.9-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:34eccd14566f8fe14b2b95bb13b11572f7c7d5c36da61caf414d23b91fcc5d94"},
{file = "psycopg2_binary-2.9.9-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:18d0ef97766055fec15b5de2c06dd8e7654705ce3e5e5eed3b6651a1d2a9a152"},
{file = "psycopg2_binary-2.9.9-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d3f82c171b4ccd83bbaf35aa05e44e690113bd4f3b7b6cc54d2219b132f3ae55"},
{file = "psycopg2_binary-2.9.9-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ead20f7913a9c1e894aebe47cccf9dc834e1618b7aa96155d2091a626e59c972"},
{file = "psycopg2_binary-2.9.9-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:ca49a8119c6cbd77375ae303b0cfd8c11f011abbbd64601167ecca18a87e7cdd"},
{file = "psycopg2_binary-2.9.9-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:323ba25b92454adb36fa425dc5cf6f8f19f78948cbad2e7bc6cdf7b0d7982e59"},
{file = "psycopg2_binary-2.9.9-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:1236ed0952fbd919c100bc839eaa4a39ebc397ed1c08a97fc45fee2a595aa1b3"},
{file = "psycopg2_binary-2.9.9-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:729177eaf0aefca0994ce4cffe96ad3c75e377c7b6f4efa59ebf003b6d398716"},
{file = "psycopg2_binary-2.9.9-cp38-cp38-win32.whl", hash = "sha256:804d99b24ad523a1fe18cc707bf741670332f7c7412e9d49cb5eab67e886b9b5"},
{file = "psycopg2_binary-2.9.9-cp38-cp38-win_amd64.whl", hash = "sha256:a6cdcc3ede532f4a4b96000b6362099591ab4a3e913d70bcbac2b56c872446f7"},
{file = "psycopg2_binary-2.9.9-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:72dffbd8b4194858d0941062a9766f8297e8868e1dd07a7b36212aaa90f49472"},
{file = "psycopg2_binary-2.9.9-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:30dcc86377618a4c8f3b72418df92e77be4254d8f89f14b8e8f57d6d43603c0f"},
{file = "psycopg2_binary-2.9.9-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:31a34c508c003a4347d389a9e6fcc2307cc2150eb516462a7a17512130de109e"},
{file = "psycopg2_binary-2.9.9-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:15208be1c50b99203fe88d15695f22a5bed95ab3f84354c494bcb1d08557df67"},
{file = "psycopg2_binary-2.9.9-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1873aade94b74715be2246321c8650cabf5a0d098a95bab81145ffffa4c13876"},
{file = "psycopg2_binary-2.9.9-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3a58c98a7e9c021f357348867f537017057c2ed7f77337fd914d0bedb35dace7"},
{file = "psycopg2_binary-2.9.9-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:4686818798f9194d03c9129a4d9a702d9e113a89cb03bffe08c6cf799e053291"},
{file = "psycopg2_binary-2.9.9-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:ebdc36bea43063116f0486869652cb2ed7032dbc59fbcb4445c4862b5c1ecf7f"},
{file = "psycopg2_binary-2.9.9-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:ca08decd2697fdea0aea364b370b1249d47336aec935f87b8bbfd7da5b2ee9c1"},
{file = "psycopg2_binary-2.9.9-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:ac05fb791acf5e1a3e39402641827780fe44d27e72567a000412c648a85ba860"},
{file = "psycopg2_binary-2.9.9-cp39-cp39-win32.whl", hash = "sha256:9dba73be7305b399924709b91682299794887cbbd88e38226ed9f6712eabee90"},
{file = "psycopg2_binary-2.9.9-cp39-cp39-win_amd64.whl", hash = "sha256:f7ae5d65ccfbebdfa761585228eb4d0df3a8b15cfb53bd953e713e09fbb12957"},
{file = "psycopg-3.2.1-py3-none-any.whl", hash = "sha256:ece385fb413a37db332f97c49208b36cf030ff02b199d7635ed2fbd378724175"},
{file = "psycopg-3.2.1.tar.gz", hash = "sha256:dc8da6dc8729dacacda3cc2f17d2c9397a70a66cf0d2b69c91065d60d5f00cb7"},
]
[package.dependencies]
psycopg-c = {version = "3.2.1", optional = true, markers = "implementation_name != \"pypy\" and extra == \"c\""}
typing-extensions = ">=4.4"
tzdata = {version = "*", markers = "sys_platform == \"win32\""}
[package.extras]
binary = ["psycopg-binary (==3.2.1)"]
c = ["psycopg-c (==3.2.1)"]
dev = ["ast-comments (>=1.1.2)", "black (>=24.1.0)", "codespell (>=2.2)", "dnspython (>=2.1)", "flake8 (>=4.0)", "mypy (>=1.6)", "types-setuptools (>=57.4)", "wheel (>=0.37)"]
docs = ["Sphinx (>=5.0)", "furo (==2022.6.21)", "sphinx-autobuild (>=2021.3.14)", "sphinx-autodoc-typehints (>=1.12)"]
pool = ["psycopg-pool"]
test = ["anyio (>=4.0)", "mypy (>=1.6)", "pproxy (>=2.7)", "pytest (>=6.2.5)", "pytest-cov (>=3.0)", "pytest-randomly (>=3.5)"]
[[package]]
name = "psycopg-c"
version = "3.2.1"
description = "PostgreSQL database adapter for Python -- C optimisation distribution"
optional = false
python-versions = ">=3.8"
files = [
{file = "psycopg_c-3.2.1.tar.gz", hash = "sha256:2d09943cc8a855c42c1e23b4298957b7ce8f27bf3683258c52fd139f601f7cda"},
]
[[package]]
@ -2228,13 +2181,13 @@ files = [
[[package]]
name = "sentry-sdk"
version = "2.11.0"
version = "2.12.0"
description = "Python client for Sentry (https://sentry.io)"
optional = false
python-versions = ">=3.6"
files = [
{file = "sentry_sdk-2.11.0-py2.py3-none-any.whl", hash = "sha256:d964710e2dbe015d9dc4ff0ad16225d68c3b36936b742a6fe0504565b760a3b7"},
{file = "sentry_sdk-2.11.0.tar.gz", hash = "sha256:4ca16e9f5c7c6bc2fb2d5c956219f4926b148e511fffdbbde711dc94f1e0468f"},
{file = "sentry_sdk-2.12.0-py2.py3-none-any.whl", hash = "sha256:7a8d5163d2ba5c5f4464628c6b68f85e86972f7c636acc78aed45c61b98b7a5e"},
{file = "sentry_sdk-2.12.0.tar.gz", hash = "sha256:8763840497b817d44c49b3fe3f5f7388d083f2337ffedf008b2cdb63b5c86dc6"},
]
[package.dependencies]
@ -2264,7 +2217,7 @@ langchain = ["langchain (>=0.0.210)"]
loguru = ["loguru (>=0.5)"]
openai = ["openai (>=1.0.0)", "tiktoken (>=0.3.0)"]
opentelemetry = ["opentelemetry-distro (>=0.35b0)"]
opentelemetry-experimental = ["opentelemetry-instrumentation-aio-pika (==0.46b0)", "opentelemetry-instrumentation-aiohttp-client (==0.46b0)", "opentelemetry-instrumentation-aiopg (==0.46b0)", "opentelemetry-instrumentation-asgi (==0.46b0)", "opentelemetry-instrumentation-asyncio (==0.46b0)", "opentelemetry-instrumentation-asyncpg (==0.46b0)", "opentelemetry-instrumentation-aws-lambda (==0.46b0)", "opentelemetry-instrumentation-boto (==0.46b0)", "opentelemetry-instrumentation-boto3sqs (==0.46b0)", "opentelemetry-instrumentation-botocore (==0.46b0)", "opentelemetry-instrumentation-cassandra (==0.46b0)", "opentelemetry-instrumentation-celery (==0.46b0)", "opentelemetry-instrumentation-confluent-kafka (==0.46b0)", "opentelemetry-instrumentation-dbapi (==0.46b0)", "opentelemetry-instrumentation-django (==0.46b0)", "opentelemetry-instrumentation-elasticsearch (==0.46b0)", "opentelemetry-instrumentation-falcon (==0.46b0)", "opentelemetry-instrumentation-fastapi (==0.46b0)", "opentelemetry-instrumentation-flask (==0.46b0)", "opentelemetry-instrumentation-grpc (==0.46b0)", "opentelemetry-instrumentation-httpx (==0.46b0)", "opentelemetry-instrumentation-jinja2 (==0.46b0)", "opentelemetry-instrumentation-kafka-python (==0.46b0)", "opentelemetry-instrumentation-logging (==0.46b0)", "opentelemetry-instrumentation-mysql (==0.46b0)", "opentelemetry-instrumentation-mysqlclient (==0.46b0)", "opentelemetry-instrumentation-pika (==0.46b0)", "opentelemetry-instrumentation-psycopg (==0.46b0)", "opentelemetry-instrumentation-psycopg2 (==0.46b0)", "opentelemetry-instrumentation-pymemcache (==0.46b0)", "opentelemetry-instrumentation-pymongo (==0.46b0)", "opentelemetry-instrumentation-pymysql (==0.46b0)", "opentelemetry-instrumentation-pyramid (==0.46b0)", "opentelemetry-instrumentation-redis (==0.46b0)", "opentelemetry-instrumentation-remoulade (==0.46b0)", "opentelemetry-instrumentation-requests (==0.46b0)", "opentelemetry-instrumentation-sklearn (==0.46b0)", "opentelemetry-instrumentation-sqlalchemy (==0.46b0)", "opentelemetry-instrumentation-sqlite3 (==0.46b0)", "opentelemetry-instrumentation-starlette (==0.46b0)", "opentelemetry-instrumentation-system-metrics (==0.46b0)", "opentelemetry-instrumentation-threading (==0.46b0)", "opentelemetry-instrumentation-tornado (==0.46b0)", "opentelemetry-instrumentation-tortoiseorm (==0.46b0)", "opentelemetry-instrumentation-urllib (==0.46b0)", "opentelemetry-instrumentation-urllib3 (==0.46b0)", "opentelemetry-instrumentation-wsgi (==0.46b0)"]
opentelemetry-experimental = ["opentelemetry-distro"]
pure-eval = ["asttokens", "executing", "pure-eval"]
pymongo = ["pymongo (>=3.1)"]
pyspark = ["pyspark (>=2.4.4)"]
@ -2632,4 +2585,4 @@ filelock = ">=3.4"
[metadata]
lock-version = "2.0"
python-versions = "^3.10"
content-hash = "a9573a584420b00b0bd5bb85a0fb2daedb365bd1ff604b94ec23c187bc4dd991"
content-hash = "30107b3b01a30323c162e09f556fd56ca8e2c338e875d7ea87583a25195645a7"

View File

@ -29,7 +29,7 @@ mistune = "^3.0.2"
django-jinja = "^2.11"
cryptography = "^43.0.0"
django-phonenumber-field = "^8.0.0"
phonenumbers = "^8.12"
phonenumbers = "^8.13"
django-ajax-selects = "^2.2.1"
reportlab = "^4.2"
django-haystack = "^3.2.1"
@ -38,18 +38,21 @@ libsass = "^0.23"
django-ordered-model = "^3.7"
django-simple-captcha = "^0.6.0"
python-dateutil = "^2.8.2"
sentry-sdk = "^2.11.0"
sentry-sdk = "^2.12.0"
pygraphviz = "^1.1"
Jinja2 = "^3.1"
django-countries = "^7.5.1"
dict2xml = "^1.7.3"
Sphinx = "^5" # Needed for building xapian
tomli = "^2.0.1"
django-honeypot = "^1.2.0"
django-honeypot = "^1.2.1"
[tool.poetry.group.prod.dependencies]
# deps used in prod, but unnecessary for development
psycopg2-binary = "^2.9"
# The C extra triggers compilation against sytem libs during install.
# Removing it would switch psycopg to a slower full-python implementation
psycopg = {extras = ["c"], version = "^3.2.1"}
redis = {extras = ["hiredis"], version = "^5.0.8"}
[tool.poetry.group.prod]
@ -62,7 +65,7 @@ ipython = "^8.26.0"
pre-commit = "^3.8.0"
ruff = "^0.5.5" # Version used in pipeline is controlled by pre-commit hooks in .pre-commit.config.yaml
djhtml = "^3.0.6"
faker = "^26.0.0"
faker = "^26.1.0"
[tool.poetry.group.tests.dependencies]
# deps used for testing purposes
@ -100,6 +103,7 @@ select = [
"FBT", # boolean trap
"UP008", # Use super() instead of super(__class__, self)
"UP009", # utf-8 encoding declaration is unnecessary
"T2", # print statements
]
ignore = [

View File

@ -40,7 +40,7 @@ class Command(BaseCommand):
user = User.objects.filter(id=options["user_id"]).first()
if user is None:
print("User with ID %s not found" % (options["user_id"],))
self.stderr.write("User with ID %s not found" % (options["user_id"],))
exit(1)
confirm = input(
@ -49,7 +49,7 @@ class Command(BaseCommand):
)
if not confirm.lower().startswith("y"):
print("Operation aborted")
self.stderr.write("Operation aborted")
exit(1)
delete_all_forum_user_messages(user, User.objects.get(id=0), verbose=True)

View File

@ -21,6 +21,7 @@
# Place - Suite 330, Boston, MA 02111-1307, USA.
#
#
import logging
from ajax_select.fields import AutoCompleteSelectField
from django import forms
@ -146,7 +147,7 @@ def delete_all_forum_user_messages(user, moderator, *, verbose=False):
continue
if verbose:
print(message)
logging.getLogger("django").info(message)
ForumMessageMeta(message=message, user=moderator, action="DELETE").save()

View File

@ -1,9 +1,11 @@
from django.conf import settings
from django.db.models import F
from ninja import Query
from ninja_extra import ControllerBase, api_controller, route
from ninja_extra import ControllerBase, api_controller, paginate, route
from ninja_extra.exceptions import PermissionDenied
from ninja_extra.pagination import PageNumberPaginationExtra
from ninja_extra.permissions import IsAuthenticated
from ninja_extra.schemas import PaginatedResponseSchema
from pydantic import NonNegativeInt
from core.models import User
@ -15,10 +17,11 @@ from sas.schemas import PictureFilterSchema, PictureSchema
class PicturesController(ControllerBase):
@route.get(
"",
response=list[PictureSchema],
response=PaginatedResponseSchema[PictureSchema],
permissions=[IsAuthenticated],
url_name="pictures",
)
@paginate(PageNumberPaginationExtra, page_size=100)
def fetch_pictures(self, filters: Query[PictureFilterSchema]):
"""Find pictures viewable by the user corresponding to the given filters.
@ -38,23 +41,12 @@ class PicturesController(ControllerBase):
cf. https://ae.utbm.fr/user/32663/pictures/)
"""
user: User = self.context.request.user
if not user.is_subscribed and filters.users_identified != {user.id}:
# User can view any moderated picture if he/she is subscribed.
# If not, he/she can view only the one he/she has been identified on
raise PermissionDenied
pictures = list(
filters.filter(
Picture.objects.filter(is_moderated=True, asked_for_removal=False)
)
return (
filters.filter(Picture.objects.viewable_by(user))
.distinct()
.order_by("-date")
.order_by("-parent__date", "date")
.annotate(album=F("parent__name"))
)
for picture in pictures:
picture.full_size_url = picture.get_download_url()
picture.compressed_url = picture.get_download_compressed_url()
picture.thumb_url = picture.get_download_thumb_url()
return pictures
@api_controller("/sas/relation", tags="User identification on SAS pictures")

View File

@ -13,11 +13,14 @@
#
#
from __future__ import annotations
from io import BytesIO
from django.conf import settings
from django.core.cache import cache
from django.db import models
from django.db.models import Exists, OuterRef
from django.urls import reverse
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
@ -27,21 +30,60 @@ from core.models import SithFile, User
from core.utils import exif_auto_rotate, resize_image
class SasFile(SithFile):
"""Proxy model for any file in the SAS.
May be used to have logic that should be shared by both
[Picture][sas.models.Picture] and [Album][sas.models.Album].
"""
class Meta:
proxy = True
def can_be_viewed_by(self, user):
if user.is_anonymous:
return False
cache_key = (
f"sas:{self._meta.model_name}_viewable_by_{user.id}_in_{self.parent_id}"
)
viewable: list[int] | None = cache.get(cache_key)
if viewable is None:
viewable = list(
self.__class__.objects.filter(parent_id=self.parent_id)
.viewable_by(user)
.values_list("pk", flat=True)
)
cache.set(cache_key, viewable, timeout=10)
return self.id in viewable
def can_be_edited_by(self, user):
return user.is_root or user.is_in_group(pk=settings.SITH_GROUP_SAS_ADMIN_ID)
class PictureQuerySet(models.QuerySet):
def viewable_by(self, user: User) -> PictureQuerySet:
"""Filter the pictures that this user can view.
Warnings:
Calling this queryset method may add several additional requests.
"""
if user.is_root or user.is_in_group(pk=settings.SITH_GROUP_SAS_ADMIN_ID):
return self.all()
if user.was_subscribed:
return self.filter(is_moderated=True)
return self.filter(people__user_id=user.id, is_moderated=True)
class SASPictureManager(models.Manager):
def get_queryset(self):
return super().get_queryset().filter(is_in_sas=True, is_folder=False)
class SASAlbumManager(models.Manager):
def get_queryset(self):
return super().get_queryset().filter(is_in_sas=True, is_folder=True)
class Picture(SithFile):
class Picture(SasFile):
class Meta:
proxy = True
objects = SASPictureManager()
objects = SASPictureManager.from_queryset(PictureQuerySet)()
@property
def is_vertical(self):
@ -50,29 +92,6 @@ class Picture(SithFile):
(w, h) = im.size
return (w / h) < 1
def can_be_edited_by(self, user):
perm = cache.get("%d_can_edit_pictures" % (user.id), None)
if perm is None:
perm = user.is_root or user.is_in_group(pk=settings.SITH_GROUP_SAS_ADMIN_ID)
cache.set("%d_can_edit_pictures" % (user.id), perm, timeout=4)
return perm
def can_be_viewed_by(self, user):
# SAS pictures are visible to old subscribers
# Result is cached 4s for this user
if user.is_anonymous:
return False
perm = cache.get("%d_can_view_pictures" % (user.id), False)
if not perm:
perm = user.was_subscribed
cache.set("%d_can_view_pictures" % (user.id), perm, timeout=4)
return (perm and self.is_moderated and self.is_in_sas) or self.can_be_edited_by(
user
)
def get_download_url(self):
return reverse("sas:download", kwargs={"picture_id": self.id})
@ -124,48 +143,53 @@ class Picture(SithFile):
def get_next(self):
if self.is_moderated:
return (
self.parent.children.filter(
is_moderated=True,
asked_for_removal=False,
is_folder=False,
id__gt=self.id,
)
.order_by("id")
.first()
pictures_qs = self.parent.children.filter(
is_moderated=True,
asked_for_removal=False,
is_folder=False,
id__gt=self.id,
)
else:
return (
Picture.objects.filter(id__gt=self.id, is_moderated=False)
.order_by("id")
.first()
)
pictures_qs = Picture.objects.filter(id__gt=self.id, is_moderated=False)
return pictures_qs.order_by("id").first()
def get_previous(self):
if self.is_moderated:
return (
self.parent.children.filter(
is_moderated=True,
asked_for_removal=False,
is_folder=False,
id__lt=self.id,
)
.order_by("id")
.last()
pictures_qs = self.parent.children.filter(
is_moderated=True,
asked_for_removal=False,
is_folder=False,
id__lt=self.id,
)
else:
return (
Picture.objects.filter(id__lt=self.id, is_moderated=False)
.order_by("-id")
.first()
)
pictures_qs = Picture.objects.filter(id__lt=self.id, is_moderated=False)
return pictures_qs.order_by("-id").first()
class Album(SithFile):
class AlbumQuerySet(models.QuerySet):
def viewable_by(self, user: User) -> PictureQuerySet:
"""Filter the albums that this user can view.
Warnings:
Calling this queryset method may add several additional requests.
"""
if user.is_root or user.is_in_group(pk=settings.SITH_GROUP_SAS_ADMIN_ID):
return self.all()
return self.filter(
Exists(Picture.objects.filter(parent_id=OuterRef("pk")).viewable_by(user))
)
class SASAlbumManager(models.Manager):
def get_queryset(self):
return super().get_queryset().filter(is_in_sas=True, is_folder=True)
class Album(SasFile):
class Meta:
proxy = True
objects = SASAlbumManager()
objects = SASAlbumManager.from_queryset(AlbumQuerySet)()
@property
def children_pictures(self):
@ -175,15 +199,6 @@ class Album(SithFile):
def children_albums(self):
return Album.objects.filter(parent=self)
def can_be_edited_by(self, user):
return user.is_in_group(pk=settings.SITH_GROUP_SAS_ADMIN_ID)
def can_be_viewed_by(self, user):
# file = SithFile.objects.filter(id=self.id).first()
return self.can_be_edited_by(user) or (
self.is_in_sas and self.is_moderated and user.was_subscribed
) # or user.can_view(file)
def get_absolute_url(self):
return reverse("sas:album", kwargs={"album_id": self.id})

View File

@ -3,7 +3,6 @@ from datetime import datetime
from ninja import FilterSchema, ModelSchema, Schema
from pydantic import Field, NonNegativeInt
from core.schemas import SimpleUserSchema
from sas.models import PeoplePictureRelation, Picture
@ -17,14 +16,25 @@ class PictureFilterSchema(FilterSchema):
class PictureSchema(ModelSchema):
class Meta:
model = Picture
fields = ["id", "name", "date", "size"]
fields = ["id", "name", "date", "size", "is_moderated"]
author: SimpleUserSchema = Field(validation_alias="owner")
full_size_url: str
compressed_url: str
thumb_url: str
album: str
@staticmethod
def resolve_full_size_url(obj: Picture) -> str:
return obj.get_download_url()
@staticmethod
def resolve_compressed_url(obj: Picture) -> str:
return obj.get_download_compressed_url()
@staticmethod
def resolve_thumb_url(obj: Picture) -> str:
return obj.get_download_thumb_url()
class PictureCreateRelationSchema(Schema):
user_id: NonNegativeInt

View File

@ -1,20 +1,15 @@
{% extends "core/base.jinja" %}
{% from "core/macros.jinja" import paginate %}
{%- block additional_css -%}
<link rel="stylesheet" href="{{ scss('sas/album.scss') }}">
<link rel="stylesheet" href="{{ scss('core/pagination.scss') }}">
{%- endblock -%}
{% block title %}
{% trans %}SAS{% endtrans %}
{% endblock %}
{% macro print_path(file) %}
{% if file and file.parent %}
{{ print_path(file.parent) }}
<a href="{{ url('sas:album', album_id=file.id) }}">{{ file.get_display_name() }}</a> /
{% endif %}
{% endmacro %}
{% from "sas/macros.jinja" import display_album, print_path %}
{% block content %}
@ -22,10 +17,10 @@
<a href="{{ url('sas:main') }}">SAS</a> / {{ print_path(album.parent) }} {{ album.get_display_name() }}
</code>
{% set edit_mode = user.can_edit(album) %}
{% set is_sas_admin = user.can_edit(album) %}
{% set start = timezone.now() %}
{% if edit_mode %}
{% if is_sas_admin %}
<form action="" method="post" enctype="multipart/form-data">
{% csrf_token %}
@ -53,73 +48,63 @@
{% endif %}
{% endif %}
{% if album.children_albums.count() > 0 %}
{% if children_albums|length > 0 %}
<h4>{% trans %}Albums{% endtrans %}</h4>
<div class="albums">
{% for a in album.children_albums.order_by('-date') %}
{% if a.can_be_viewed_by(user) %}
<a href="{{ url('sas:album', album_id=a.id) }}">
<div
class="album{% if not a.is_moderated %} not_moderated{% endif %}"
style="background-image: url('{% if a.file %}{{ a.get_download_url() }}{% else %}{{ static('core/img/sas.jpg') }}{% endif %}');"
>
{% if not a.is_moderated %}
<div class="overlay">&nbsp;</div>
<div class="text">{% trans %}To be moderated{% endtrans %}</div>
{% else %}
<div class="text">{{ a.name }}</div>
{% endif %}
</div>
{% if edit_mode %}
<input type="checkbox" name="file_list" value="{{ a.id }}">
{% endif %}
</a>
{% endif %}
{% for a in children_albums %}
{{ display_album(a, is_sas_admin) }}
{% endfor %}
</div>
<br>
{% endif %}
<h4>{% trans %}Pictures{% endtrans %}</h4>
{% if pictures | length != 0 %}
<div class="photos">
{% for p in pictures %}
{% if p.can_be_viewed_by(user) %}
<a href="{{ url('sas:picture', picture_id=p.id) }}#pict">
<div
class="photo {% if p.is_vertical %}vertical{% endif %}"
style="background-image: url('{{ p.get_download_thumb_url() }}')"
>
{% if not p.is_moderated %}
<div class="overlay">&nbsp;</div>
<div class="text">{% trans %}To be moderated{% endtrans %}</div>
{% else %}
<div class="text">&nbsp;</div>
{% endif %}
</div>
{% if edit_mode %}
<input type="checkbox" name="file_list" value="{{ p.id }}">
{% endif %}
</a>
{% endif %}
{% endfor %}
<div x-data="pictures">
<h4>{% trans %}Pictures{% endtrans %}</h4>
<div class="photos" :aria-busy="loading">
<template x-for="picture in pictures.results">
<a :href="`/sas/picture/${picture.id}#pict`">
<div class="photo" :style="`background-image: url(${picture.thumb_url})`">
<template x-if="!picture.is_moderated">
<div class="overlay">&nbsp;</div>
<div class="text">{% trans %}To be moderated{% endtrans %}</div>
</template>
<template x-if="picture.is_moderated">
<div class="text">&nbsp;</div>
</template>
</div>
{% if is_sas_admin %}
<input type="checkbox" name="file_list" :value="picture.id">
{% endif %}
</a>
</template>
</div>
{% else %}
{% trans %}This album does not contain any photos.{% endtrans %}
{% endif %}
<nav class="pagination" x-show="nb_pages() > 1">
{# Adding the prevent here is important, because otherwise,
clicking on the pagination buttons could submit the picture management form
and reload the page #}
<button
@click.prevent="page--"
:disabled="page <= 1"
@keyup.right.window="page = Math.min(nb_pages(), page + 1)"
>
<i class="fa fa-caret-left"></i>
</button>
<template x-for="i in nb_pages()">
<button x-text="i" @click.prevent="page = i" :class="{active: page === i}"></button>
</template>
<button
@click.prevent="page++"
:disabled="page >= nb_pages()"
@keyup.left.window="page = Math.max(1, page - 1)"
>
<i class="fa fa-caret-right"></i>
</button>
</nav>
</div>
{% if pictures.has_previous() or pictures.has_next() %}
<div class="paginator">
{{ paginate(pictures, paginator) }}
</div>
{% endif %}
{% if edit_mode %}
{% if is_sas_admin %}
</form>
{% endif %}
{% if user.is_in_group(pk=settings.SITH_GROUP_SAS_ADMIN_ID) %}
<form class="add-files" id="upload_form" action="" method="post" enctype="multipart/form-data">
{% csrf_token %}
<div class="inputs">
@ -140,6 +125,36 @@
{% block script %}
{{ super() }}
<script>
document.addEventListener("alpine:init", () => {
Alpine.data("pictures", () => ({
pictures: {},
page: parseInt(initialUrlParams.get("page")) || 1,
loading: false,
async init() {
await this.fetch_pictures();
this.$watch("page", () => {
update_query_string("page", this.page === 1 ? null : this.page);
this.fetch_pictures()
});
},
async fetch_pictures() {
this.loading=true;
const url = "{{ url("api:pictures") }}"
+"?album_id={{ album.id }}"
+`&page=${this.page}`
+"&page_size={{ settings.SITH_SAS_IMAGES_PER_PAGE }}";
this.pictures = await (await fetch(url)).json();
this.loading=false;
},
nb_pages() {
return Math.ceil(this.pictures.count / {{ settings.SITH_SAS_IMAGES_PER_PAGE }});
}
}))
})
$("form#upload_form").submit(function (event) {
let formData = new FormData($(this)[0]);

View File

@ -0,0 +1,32 @@
{% macro display_album(a, edit_mode) %}
<a href="{{ url('sas:album', album_id=a.id) }}">
{% if a.file %}
{% set img = a.get_download_url() %}
{% elif a.children.filter(is_folder=False, is_moderated=True).exists() %}
{% set img = a.children.filter(is_folder=False).first().as_picture.get_download_thumb_url() %}
{% else %}
{% set img = static('core/img/sas.jpg') %}
{% endif %}
<div
class="album{% if not a.is_moderated %} not_moderated{% endif %}"
style="background-image: url('{{ img }}');"
>
{% if not a.is_moderated %}
<div class="overlay">&nbsp;</div>
<div class="text">{% trans %}To be moderated{% endtrans %}</div>
{% else %}
<div class="text">{{ a.name }}</div>
{% endif %}
</div>
{% if edit_mode %}
<input type="checkbox" name="file_list" value="{{ a.id }}">
{% endif %}
</a>
{% endmacro %}
{% macro print_path(file) %}
{% if file and file.parent %}
{{ print_path(file.parent) }}
<a href="{{ url('sas:album', album_id=file.id) }}">{{ file.get_display_name() }}</a> /
{% endif %}
{% endmacro %}

View File

@ -8,31 +8,9 @@
{% trans %}SAS{% endtrans %}
{% endblock %}
{% set edit_mode = user.is_in_group(pk=settings.SITH_GROUP_SAS_ADMIN_ID) %}
{% set is_sas_admin = user.is_root or user.is_in_group(pk=settings.SITH_GROUP_SAS_ADMIN_ID) %}
{% macro display_album(a, checkbox) %}
<a href="{{ url('sas:album', album_id=a.id) }}">
{% if a.file %}
{% set img = a.get_download_url() %}
{% elif a.children.filter(is_folder=False, is_moderated=True).exists() %}
{% set img = a.children.filter(is_folder=False).first().as_picture.get_download_thumb_url() %}
{% else %}
{% set img = static('core/img/sas.jpg') %}
{% endif %}
<div
class="album"
style="background-image: url('{{ img }}');"
>
<div class="text">
{{ a.name }}
</div>
</div>
{# {% if edit_mode and checkbox %}
<input type="checkbox" name="file_list" value="{{ a.id }}">
{% endif %} #}
</a>
{% endmacro %}
{% from "sas/macros.jinja" import display_album %}
{% block content %}
<main>
@ -46,22 +24,18 @@
<div class="albums">
{% for a in latest %}
{{ display_album(a) }}
{{ display_album(a, edit_mode=False) }}
{% endfor %}
</div>
<br>
{% if edit_mode %}
{% if is_sas_admin %}
<form action="" method="post" enctype="multipart/form-data">
{% csrf_token %}
<div class="navbar">
<h4>{% trans %}All categories{% endtrans %}</h4>
{# <div class="toolbar">
<input name="delete" type="submit" value="{% trans %}Delete{% endtrans %}">
</div> #}
</div>
{% if clipboard %}
@ -81,11 +55,11 @@
<div class="albums">
{% for a in categories %}
{{ display_album(a, true) }}
{{ display_album(a, edit_mode=False) }}
{% endfor %}
</div>
{% if edit_mode %}
{% if is_sas_admin %}
</form>
<br>

View File

@ -4,36 +4,11 @@
<link rel="stylesheet" href="{{ scss('sas/picture.scss') }}">
{%- endblock -%}
{%- block additional_js -%}
<script src="{{ static('core/js/alpinejs.min.js') }}" defer></script>
{%- endblock -%}
{% block head %}
{{ super() }}
{% if picture.get_previous() %}
<link
rel="preload"
as="image"
href="{{ url("sas:download_compressed", picture_id=picture.get_previous().id) }}"
>
{% endif %}
{% if picture.get_next() %}
<link rel="preload" as="image" href="{{ url("sas:download_compressed", picture_id=picture.get_next().id) }}">
{% endif %}
{% endblock %}
{% block title %}
{% trans %}SAS{% endtrans %}
{% endblock %}
{% macro print_path(file) %}
{% if file and file.parent %}
{{ print_path(file.parent) }}
<a href="{{ url('sas:album', album_id=file.id) }}">{{ file.get_display_name() }}</a> /
{% endif %}
{% endmacro %}
{% from "sas/macros.jinja" import print_path %}
{% block content %}
<code>
@ -128,16 +103,16 @@
<div class="subsection">
<div class="navigation">
<div id="prev">
{% if picture.get_previous() %}
<a href="{{ url( 'sas:picture', picture_id=picture.get_previous().id) }}#pict">
<div style="background-image: url('{{ picture.get_previous().as_picture.get_download_thumb_url() }}');"></div>
{% if previous_pict %}
<a href="{{ url( 'sas:picture', picture_id=previous_pict.id) }}#pict">
<div style="background-image: url('{{ previous_pict.get_download_thumb_url() }}');"></div>
</a>
{% endif %}
</div>
<div id="next">
{% if picture.get_next() %}
<a href="{{ url( 'sas:picture', picture_id=picture.get_next().id) }}#pict">
<div style="background-image: url('{{ picture.get_next().as_picture.get_download_thumb_url() }}');"></div>
{% if next_pict %}
<a href="{{ url( 'sas:picture', picture_id=next_pict.id) }}#pict">
<div style="background-image: url('{{ next_pict.get_download_thumb_url() }}');"></div>
</a>
{% endif %}
</div>
@ -145,11 +120,13 @@
<div class="tags">
<h5>{% trans %}People{% endtrans %}</h5>
<form action="" method="post" enctype="multipart/form-data">
{% csrf_token %}
{{ form.as_p() }}
<input type="submit" value="{% trans %}Go{% endtrans %}" />
</form>
{% if user.was_subscribed %}
<form action="" method="post" enctype="multipart/form-data">
{% csrf_token %}
{{ form.as_p() }}
<input type="submit" value="{% trans %}Go{% endtrans %}" />
</form>
{% endif %}
<ul x-data="user_identification">
<template x-for="item in items" :key="item.id">
<li>

View File

@ -44,12 +44,8 @@ class TestPictureSearch(TestSas):
self.client.force_login(self.user_b)
res = self.client.get(reverse("api:pictures") + f"?album_id={self.album_a.id}")
assert res.status_code == 200
expected = list(
self.album_a.children_pictures.order_by("-date").values_list(
"id", flat=True
)
)
assert [i["id"] for i in res.json()] == expected
expected = list(self.album_a.children_pictures.values_list("id", flat=True))
assert [i["id"] for i in res.json()["results"]] == expected
def test_filter_by_user(self):
self.client.force_login(self.user_b)
@ -58,11 +54,11 @@ class TestPictureSearch(TestSas):
)
assert res.status_code == 200
expected = list(
self.user_a.pictures.order_by("-picture__date").values_list(
"picture_id", flat=True
)
self.user_a.pictures.order_by(
"-picture__parent__date", "picture__date"
).values_list("picture_id", flat=True)
)
assert [i["id"] for i in res.json()] == expected
assert [i["id"] for i in res.json()["results"]] == expected
def test_filter_by_multiple_user(self):
self.client.force_login(self.user_b)
@ -73,38 +69,53 @@ class TestPictureSearch(TestSas):
assert res.status_code == 200
expected = list(
self.user_a.pictures.union(self.user_b.pictures.all())
.order_by("-picture__date")
.order_by("-picture__parent__date", "picture__date")
.values_list("picture_id", flat=True)
)
assert [i["id"] for i in res.json()] == expected
assert [i["id"] for i in res.json()["results"]] == expected
def test_not_subscribed_user(self):
"""Test that a user that is not subscribed can only its own pictures."""
"""Test that a user that never subscribed can only its own pictures."""
self.user_a.subscriptions.all().delete()
self.client.force_login(self.user_a)
res = self.client.get(
reverse("api:pictures") + f"?users_identified={self.user_a.id}"
)
assert res.status_code == 200
expected = list(
self.user_a.pictures.order_by("-picture__date").values_list(
"picture_id", flat=True
)
self.user_a.pictures.order_by(
"-picture__parent__date", "picture__date"
).values_list("picture_id", flat=True)
)
assert [i["id"] for i in res.json()] == expected
assert [i["id"] for i in res.json()["results"]] == expected
# trying to access the pictures of someone else
res = self.client.get(
reverse("api:pictures") + f"?users_identified={self.user_b.id}"
)
assert res.status_code == 403
# trying to access the pictures of someone else shouldn't success,
# even if mixed with owned pictures
# trying to access the pictures of someone else mixed with owned pictures
# should return only owned pictures
res = self.client.get(
reverse("api:pictures")
+ f"?users_identified={self.user_a.id}&users_identified={self.user_b.id}"
)
assert res.status_code == 403
assert res.status_code == 200
assert [i["id"] for i in res.json()["results"]] == expected
# trying to fetch everything should be the same
# as fetching its own pictures for a non-subscriber
res = self.client.get(reverse("api:pictures"))
assert res.status_code == 200
assert [i["id"] for i in res.json()["results"]] == expected
# trying to access the pictures of someone else should return only
# the ones where the non-subscribed user is identified too
res = self.client.get(
reverse("api:pictures") + f"?users_identified={self.user_b.id}"
)
assert res.status_code == 200
expected = list(
self.user_b.pictures.intersection(self.user_a.pictures.all())
.order_by("-picture__parent__date", "picture__date")
.values_list("picture_id", flat=True)
)
assert [i["id"] for i in res.json()["results"]] == expected
class TestPictureRelation(TestSas):

48
sas/tests/test_model.py Normal file
View File

@ -0,0 +1,48 @@
from django.test import TestCase
from model_bakery import baker, seq
from core.baker_recipes import old_subscriber_user, subscriber_user
from core.models import User
from sas.models import Picture
class TestPictureQuerySet(TestCase):
@classmethod
def setUpTestData(cls):
Picture.objects.all().delete()
cls.pictures = baker.make(
Picture,
is_moderated=True,
is_in_sas=True,
is_folder=False,
name=seq(""),
_quantity=10,
_bulk_create=True,
)
Picture.objects.filter(pk=cls.pictures[0].id).update(is_moderated=False)
def test_root(self):
root = baker.make(User, is_superuser=True)
pictures = list(Picture.objects.viewable_by(root))
self.assertCountEqual(pictures, self.pictures)
def test_subscriber(self):
subscriber = subscriber_user.make()
old_subcriber = old_subscriber_user.make()
for user in (subscriber, old_subcriber):
pictures = list(Picture.objects.viewable_by(user))
self.assertCountEqual(pictures, self.pictures[1:])
def test_not_subscribed_identified(self):
user = baker.make(
# This is the guy who asked the feature of making pictures
# available for tagged users, even if not subscribed
User,
first_name="Pierrick",
last_name="Dheilly",
nick_name="Sahmer",
)
user.pictures.create(picture=self.pictures[0])
user.pictures.create(picture=self.pictures[1])
pictures = list(Picture.objects.viewable_by(user))
assert pictures == [self.pictures[1]]

View File

@ -18,7 +18,6 @@ from ajax_select.fields import AutoCompleteSelectMultipleField
from django import forms
from django.conf import settings
from django.core.exceptions import PermissionDenied
from django.core.paginator import InvalidPage, Paginator
from django.http import Http404, HttpResponse
from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse, reverse_lazy
@ -120,10 +119,11 @@ class SASMainView(FormView):
def get_context_data(self, **kwargs):
kwargs = super().get_context_data(**kwargs)
kwargs["categories"] = Album.objects.filter(
parent__id=settings.SITH_SAS_ROOT_DIR_ID
).order_by("id")
kwargs["latest"] = Album.objects.filter(is_moderated=True).order_by("-id")[:5]
albums_qs = Album.objects.viewable_by(self.request.user)
kwargs["categories"] = list(
albums_qs.filter(parent_id=settings.SITH_SAS_ROOT_DIR_ID).order_by("id")
)
kwargs["latest"] = list(albums_qs.order_by("-id")[:5])
return kwargs
@ -181,7 +181,14 @@ class PictureView(CanViewMixin, DetailView, FormMixin):
def get_context_data(self, **kwargs):
kwargs = super().get_context_data(**kwargs)
pictures_qs = Picture.objects.viewable_by(self.request.user)
kwargs["form"] = self.form
kwargs["next_pict"] = (
pictures_qs.filter(id__gt=self.object.id).order_by("id").first()
)
kwargs["previous_pict"] = (
pictures_qs.filter(id__lt=self.object.id).order_by("-id").first()
)
return kwargs
def get_success_url(self):
@ -222,8 +229,9 @@ class AlbumUploadView(CanViewMixin, DetailView, FormMixin):
parent=parent,
owner=request.user,
files=files,
automodere=request.user.is_in_group(
pk=settings.SITH_GROUP_SAS_ADMIN_ID
automodere=(
request.user.is_in_group(pk=settings.SITH_GROUP_SAS_ADMIN_ID)
or request.user.is_root
),
)
if self.form.is_valid():
@ -236,7 +244,6 @@ class AlbumView(CanViewMixin, DetailView, FormMixin):
form_class = SASForm
pk_url_kwarg = "album_id"
template_name = "sas/album.jinja"
paginate_by = settings.SITH_SAS_IMAGES_PER_PAGE
def dispatch(self, request, *args, **kwargs):
try:
@ -283,17 +290,15 @@ class AlbumView(CanViewMixin, DetailView, FormMixin):
def get_context_data(self, **kwargs):
kwargs = super().get_context_data(**kwargs)
kwargs["paginator"] = Paginator(
self.object.children_pictures.order_by("id"), self.paginate_by
)
try:
kwargs["pictures"] = kwargs["paginator"].page(self.asked_page)
except InvalidPage as e:
raise Http404 from e
kwargs["form"] = self.form
kwargs["clipboard"] = SithFile.objects.filter(
id__in=self.request.session["clipboard"]
)
kwargs["children_albums"] = list(
Album.objects.viewable_by(self.request.user)
.filter(parent_id=self.object.id)
.order_by("-date")
)
return kwargs
@ -326,9 +331,7 @@ class ModerationView(TemplateView):
kwargs["albums_to_moderate"] = Album.objects.filter(
is_moderated=False, is_in_sas=True, is_folder=True
).order_by("id")
kwargs["pictures"] = Picture.objects.filter(
is_moderated=False, is_in_sas=True, is_folder=False
)
kwargs["pictures"] = Picture.objects.filter(is_moderated=False)
kwargs["albums"] = Album.objects.filter(
id__in=kwargs["pictures"].values("parent").distinct("parent")
)

View File

@ -34,6 +34,7 @@ https://docs.djangoproject.com/en/1.8/ref/settings/
"""
import binascii
import logging
import os
import sys
from pathlib import Path
@ -98,6 +99,7 @@ INSTALLED_APPS = (
"matmat",
"pedagogy",
"galaxy",
"antispam",
)
MIDDLEWARE = (
@ -204,13 +206,12 @@ SASS_PRECISION = 8
WSGI_APPLICATION = "sith.wsgi.application"
# Database
# https://docs.djangoproject.com/en/1.8/ref/settings/#databases
DATABASES = {
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": BASE_DIR / "db.sqlite3",
}
},
}
SESSION_ENGINE = "django.contrib.sessions.backends.cached_db"
@ -617,11 +618,14 @@ SITH_EBOUTIC_CB_ENABLED = True
SITH_EBOUTIC_ET_URL = (
"https://preprod-tpeweb.e-transactions.fr/cgi/MYchoix_pagepaiement.cgi"
)
SITH_EBOUTIC_PBX_SITE = "4000666"
SITH_EBOUTIC_PBX_RANG = "42"
SITH_EBOUTIC_PBX_IDENTIFIANT = "123456789"
SITH_EBOUTIC_PBX_SITE = "1999888"
SITH_EBOUTIC_PBX_RANG = "32"
SITH_EBOUTIC_PBX_IDENTIFIANT = "2"
SITH_EBOUTIC_HMAC_KEY = binascii.unhexlify(
"0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF"
"0123456789ABCDEF0123456789ABCDEF"
"0123456789ABCDEF0123456789ABCDEF"
"0123456789ABCDEF0123456789ABCDEF"
"0123456789ABCDEF0123456789ABCDEF"
)
SITH_EBOUTIC_PUB_KEY = ""
with open(os.path.join(os.path.dirname(__file__), "et_keys/pubkey.pem")) as f:
@ -673,12 +677,16 @@ SITH_GIFT_LIST = [("AE Tee-shirt", _("AE tee-shirt"))]
SENTRY_DSN = ""
SENTRY_ENV = "production"
TOXIC_DOMAINS_PROVIDERS = [
"https://www.stopforumspam.com/downloads/toxic_domains_whole.txt",
]
try:
from .settings_custom import *
print("Custom settings imported", file=sys.stderr)
logging.getLogger("django").info("Custom settings imported")
except:
print("Custom settings failed", file=sys.stderr)
logging.getLogger("django").warning("Custom settings failed")
if DEBUG:
INSTALLED_APPS += ("debug_toolbar",)
@ -734,7 +742,7 @@ SITH_FRONT_DEP_VERSIONS = {
"https://github.com/viralpatel/jquery.shorten/": "",
"https://github.com/getsentry/sentry-javascript/": "4.0.6",
"https://github.com/jhuckaby/webcamjs/": "1.0.0",
"https://github.com/alpinejs/alpine": "3.10.5",
"https://github.com/alpinejs/alpine": "3.14.1",
"https://github.com/mrdoob/three.js/": "r148",
"https://github.com/vasturiano/three-spritetext": "1.6.5",
"https://github.com/vasturiano/3d-force-graph/": "1.70.19",