mirror of
https://github.com/ae-utbm/sith.git
synced 2024-11-22 14:13:21 +00:00
Merge pull request #773 from ae-utbm/taiste
SAS, Eboutic, Antispam, psycopg
This commit is contained in:
commit
f5cee10761
2
.gitignore
vendored
2
.gitignore
vendored
@ -1,4 +1,4 @@
|
||||
db.sqlite3
|
||||
*.sqlite3
|
||||
*.log
|
||||
*.pyc
|
||||
*.mo
|
||||
|
0
antispam/__init__.py
Normal file
0
antispam/__init__.py
Normal file
10
antispam/admin.py
Normal file
10
antispam/admin.py
Normal 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
7
antispam/apps.py
Normal 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
18
antispam/forms.py
Normal 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."))
|
0
antispam/management/commands/__init__.py
Normal file
0
antispam/management/commands/__init__.py
Normal file
69
antispam/management/commands/update_spam_database.py
Normal file
69
antispam/management/commands/update_spam_database.py
Normal 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)
|
35
antispam/migrations/0001_initial.py
Normal file
35
antispam/migrations/0001_initial.py
Normal 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",
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
]
|
0
antispam/migrations/__init__.py
Normal file
0
antispam/migrations/__init__.py
Normal file
19
antispam/models.py
Normal file
19
antispam/models.py
Normal 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
|
@ -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,
|
||||
|
@ -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
|
||||
|
||||
|
||||
|
@ -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")
|
||||
|
@ -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)
|
||||
|
@ -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":
|
||||
@property
|
||||
def is_file(self):
|
||||
return not self.is_folder
|
||||
else:
|
||||
return super().__getattribute__(attr)
|
||||
|
||||
@cached_property
|
||||
def as_picture(self):
|
||||
|
4
core/static/core/js/alpinejs.min.js
vendored
4
core/static/core/js/alpinejs.min.js
vendored
File diff suppressed because one or more lines are too long
@ -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());
|
||||
}
|
||||
|
@ -3,6 +3,7 @@
|
||||
.pagination {
|
||||
text-align: center;
|
||||
gap: 10px;
|
||||
margin: 30px;
|
||||
|
||||
button {
|
||||
background-color: $secondary-neutral-light-color;
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
|
@ -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>
|
||||
|
||||
|
@ -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">
|
||||
|
@ -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({
|
||||
|
@ -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
|
||||
|
@ -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,
|
||||
|
@ -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)
|
||||
|
@ -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):
|
||||
|
@ -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
|
||||
|
||||
|
||||
|
@ -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:
|
||||
if hasattr(self.product, "eticket"):
|
||||
self.send_mail_customer()
|
||||
except:
|
||||
pass
|
||||
|
||||
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})
|
||||
|
@ -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 %}
|
||||
|
271
counter/tests.py
271
counter/tests.py
@ -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):
|
||||
|
@ -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
35
counter/utils.py
Normal 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()
|
||||
)
|
@ -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)
|
||||
|
1
docs/reference/antispam/forms.md
Normal file
1
docs/reference/antispam/forms.md
Normal file
@ -0,0 +1 @@
|
||||
::: antispam.forms
|
1
docs/reference/antispam/models.md
Normal file
1
docs/reference/antispam/models.md
Normal file
@ -0,0 +1 @@
|
||||
::: antispam.models
|
275
docs/tutorial/install-advanced.md
Normal file
275
docs/tutorial/install-advanced.md
Normal 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
|
||||
```
|
@ -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.
|
||||
|
||||
|
||||
|
@ -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
38
eboutic/api.py
Normal 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())
|
101
eboutic/forms.py
101
eboutic/forms.py
@ -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
|
||||
|
@ -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"))
|
||||
@cached_property
|
||||
def total(self) -> float:
|
||||
return float(
|
||||
self.items.aggregate(
|
||||
total=Sum(F("quantity") * F("product_unit_price"), default=0)
|
||||
)["total"]
|
||||
return float(total) if total is not None else 0
|
||||
)
|
||||
|
||||
@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"))
|
||||
return float(
|
||||
self.items.aggregate(
|
||||
total=Sum(F("quantity") * F("product_unit_price"), default=0)
|
||||
)["total"]
|
||||
return float(total) if total is not None else 0
|
||||
)
|
||||
|
||||
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
33
eboutic/schemas.py
Normal 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"]
|
@ -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);
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
@ -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 "";
|
||||
},
|
||||
}));
|
||||
});
|
||||
|
@ -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 }}">
|
||||
|
@ -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"
|
||||
<form
|
||||
class="collapse-body"
|
||||
id="billing_info_form"
|
||||
x-data="billing_infos"
|
||||
x-show="collapsed"
|
||||
x-transition.scale.origin.top
|
||||
@submit.prevent="send_form()">
|
||||
@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"
|
||||
<input
|
||||
type="submit"
|
||||
id="bank-submit-button"
|
||||
{% if must_fill_billing_infos %}disabled="disabled"{% endif %}
|
||||
value="{% trans %}Pay with credit card{% endtrans %}"/>
|
||||
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() }}
|
||||
|
@ -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/")
|
||||
|
||||
|
@ -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,16 +158,19 @@ 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")
|
||||
else:
|
||||
eboutic = Counter.objects.filter(type="EBOUTIC").first()
|
||||
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()
|
||||
@ -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"
|
||||
)
|
||||
|
@ -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
@ -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
|
||||
|
@ -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):
|
||||
|
@ -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
135
poetry.lock
generated
@ -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"
|
||||
|
@ -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 = [
|
||||
|
@ -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)
|
||||
|
@ -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()
|
||||
|
||||
|
||||
|
24
sas/api.py
24
sas/api.py
@ -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")
|
||||
|
135
sas/models.py
135
sas/models.py
@ -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(
|
||||
pictures_qs = self.parent.children.filter(
|
||||
is_moderated=True,
|
||||
asked_for_removal=False,
|
||||
is_folder=False,
|
||||
id__gt=self.id,
|
||||
)
|
||||
.order_by("id")
|
||||
.first()
|
||||
)
|
||||
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(
|
||||
pictures_qs = self.parent.children.filter(
|
||||
is_moderated=True,
|
||||
asked_for_removal=False,
|
||||
is_folder=False,
|
||||
id__lt=self.id,
|
||||
)
|
||||
.order_by("id")
|
||||
.last()
|
||||
)
|
||||
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 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 Album(SithFile):
|
||||
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})
|
||||
|
||||
|
@ -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
|
||||
|
@ -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"> </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 %}
|
||||
|
||||
<div x-data="pictures">
|
||||
<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="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"> </div>
|
||||
<div class="text">{% trans %}To be moderated{% endtrans %}</div>
|
||||
{% else %}
|
||||
</template>
|
||||
<template x-if="picture.is_moderated">
|
||||
<div class="text"> </div>
|
||||
{% endif %}
|
||||
</template>
|
||||
</div>
|
||||
{% if edit_mode %}
|
||||
<input type="checkbox" name="file_list" value="{{ p.id }}">
|
||||
{% if is_sas_admin %}
|
||||
<input type="checkbox" name="file_list" :value="picture.id">
|
||||
{% endif %}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</template>
|
||||
</div>
|
||||
{% else %}
|
||||
{% trans %}This album does not contain any photos.{% endtrans %}
|
||||
{% endif %}
|
||||
|
||||
{% if pictures.has_previous() or pictures.has_next() %}
|
||||
<div class="paginator">
|
||||
{{ paginate(pictures, paginator) }}
|
||||
<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>
|
||||
{% 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]);
|
||||
|
||||
|
32
sas/templates/sas/macros.jinja
Normal file
32
sas/templates/sas/macros.jinja
Normal 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"> </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 %}
|
@ -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>
|
||||
|
@ -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>
|
||||
{% 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>
|
||||
|
@ -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
48
sas/tests/test_model.py
Normal 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]]
|
39
sas/views.py
39
sas/views.py
@ -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")
|
||||
)
|
||||
|
@ -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",
|
||||
|
Loading…
Reference in New Issue
Block a user