{"config":{"lang":["fr"],"separator":"[\\s\\-]+","pipeline":["stopWordFilter"]},"docs":[{"location":"","title":"Documentation du site de l'association des \u00e9tudiants de l'UTBM","text":"
Bonjour, camarade.
Si tu es ici, c'est sans doute pour mieux connaitre le fonctionnement interne du site AE. Peut-\u00eatre m\u00eame as-tu envie de contribuer \u00e0 son d\u00e9veloppement. Si tel est le cas, nous ne pouvons faire autrement que te souhaiter la bienvenue sur cette documentation et esp\u00e9rer que ce que nous avons \u00e9crit est assez clair et assez complet pour satisfaire ton esprit en qu\u00eate de connaissances.
Et si tu viens pour d'autres motifs, \u00e7a ne change rien, soit le bienvenu (ou la bienvenue) quand m\u00eame.
Pour que tu saches o\u00f9 chercher quelles informations, voici comment nous avons d\u00e9coup\u00e9 la documentation :
Le but de ce projet est de fournir \u00e0 l'Association des \u00c9tudiants de l'UTBM une plate-forme pratique et centralis\u00e9e de ses services. Le Sith de l'AE tient \u00e0 jour le registre des cotisations \u00e0 l'association, prend en charge la tr\u00e9sorerie, les ventes de produits et services, la diffusion d\u2019\u00e9v\u00e9nements, la gestion de la laverie et bien plus encore.
C'est un projet b\u00e9n\u00e9vole qui tire ses origines des ann\u00e9es 2000. Il s'agit de la troisi\u00e8me version du site de l'AE. Son d\u00e9veloppement a commenc\u00e9 en 2015. C'est une r\u00e9\u00e9criture compl\u00e8te en rupture totale des deux versions qui l'ont pr\u00e9c\u00e9d\u00e9e.
"},{"location":"explanation/#pourquoi-reecrire-le-site","title":"Pourquoi r\u00e9\u00e9crire le site","text":"L'ancienne version du site, sobrement baptis\u00e9e ae2, pr\u00e9sentait un nombre impressionnant de fonctionnalit\u00e9s. Il avait \u00e9t\u00e9 \u00e9crit en PHP et se basait sur son propre framework maison.
Malheureusement, son entretien \u00e9tait plus ou moins hasardeux et son framework reposait sur des principes assez diff\u00e9rents de ce qui se fait aujourd'hui, rendant la maintenance difficile. De plus, la version de PHP qu'il utilisait \u00e9tait plus que d\u00e9pr\u00e9ci\u00e9e et \u00e0 l'heure de l'arriv\u00e9e de PHP 7 et de sa non-r\u00e9trocompatibilit\u00e9 il \u00e9tait vital de faire quelque chose. Il a donc \u00e9t\u00e9 d\u00e9cid\u00e9 de le r\u00e9\u00e9crire.
"},{"location":"explanation/#la-philosophie-initiale","title":"La philosophie initiale","text":"Pour \u00e9viter les erreurs du pass\u00e9, ce projet met l'accent sur la maintenabilit\u00e9. Le choix des technologies ne s'est donc pas fait uniquement sur le fait qu'elle soit r\u00e9centes, mais \u00e9galement sur leur robustesse, leur fiabilit\u00e9 et leur potentiel \u00e0 \u00eatre maintenu loin dans le futur.
La maintenabilit\u00e9 passe \u00e9galement par le choix minutieux des d\u00e9pendances qui doivent, elles aussi, passer l'\u00e9preuve du temps pour \u00e9viter qu'elles ne mettent le projet en danger.
Cela passe \u00e9galement par la minimisation des frameworks employ\u00e9s de mani\u00e8re \u00e0 r\u00e9duire un maximum les connaissances n\u00e9cessaires pour contribuer au projet et donc simplifier la prise en main. La simplicit\u00e9 est \u00e0 privil\u00e9gier si elle est possible.
Le projet doit \u00eatre simple \u00e0 installer et \u00e0 d\u00e9ployer.
Le projet \u00e9tant \u00e0 destination d'\u00e9tudiants, il est pr\u00e9f\u00e9rable de minimiser les ressources utilis\u00e9es par l'utilisateur final. Il faut qu'il soit au maximum \u00e9conome en bande passante et calcul c\u00f4t\u00e9 client.
Le projet est un logiciel libre et est sous licence GPL. Aucune d\u00e9pendance propri\u00e9taire n'est accept\u00e9e.
"},{"location":"explanation/#la-philosophie-10-ans-plus-tard","title":"La philosophie, 10 ans plus tard","text":"Malgr\u00e9 la bonne volont\u00e9 et le travail colossal fourni par les developpeurs de la version actuelle du projet, force est de constater que nombre d'erreurs ont malheureusement \u00e9t\u00e9 commises : usage complexe et excessif de certains m\u00e9canismes OO, r\u00e9\u00e9criture maison de fonctionnalit\u00e9s de Django, syst\u00e8me de gestion des permissions rigide et co\u00fbteux en requ\u00eate \u00e0 la base de donn\u00e9es...
Mais malgr\u00e9 tout \u00e7a, le site tourne. En effet, force est de constater que le pari initial de choisir un framework stable et durable a pay\u00e9. Aujourd'hui encore, Django est activement maintenu, ses mises \u00e0 jour sont r\u00e9guli\u00e8res sans pour autant n\u00e9cessiter beaucoup de changements lors des changements de version majeure.
Quant aux erreurs qui ont \u00e9t\u00e9 commises, que celui qui n'a jamais reconsid\u00e9r\u00e9 a posteriori que ce qui lui semblait une bonne architecture \u00e9tait en fait un ensemble brinquebalant, leur jette la premi\u00e8re pierre.
La solidit\u00e9 des fondations ayant \u00e9t\u00e9 prouv\u00e9e par l'\u00e9preuve du temps, le travail restant \u00e0 accomplir n'est pas de r\u00e9\u00e9crire encore une fois le site en utilisant encore d'autres technologies, mais plut\u00f4t de raboter les surcouches du site, pour refixer le plus solidement possiblement le projet sur ces fondations.
"},{"location":"explanation/archives/","title":"Archives","text":"Page g\u00e9n\u00e9r\u00e9e
Cette page est g\u00e9n\u00e9r\u00e9e \u00e0 partir du wiki GitHub du projet puisqu'elle h\u00e9berge du contenu binaire lourd qui encombrerait le repo git. La page originale ici peut \u00eatre plus \u00e0 jour puisque le contenu de cette page date de la derni\u00e8re g\u00e9n\u00e9ration de la documentation.
Cette page contient certains des pdf qui ont pu \u00eatre produits dans le cadre du travail pass\u00e9 sur le site. Vous ne trouverez pas des informations compl\u00e8tes et \u00e0 jour. En revanche, vous trouverez des morceaux d'histoire qui vous permettront de mieux comprendre ceux \u00e0 quoi pensaient les anciens d\u00e9veloppeurs et, par extension, vous comprendrez peut-\u00eatre un peu mieux le site.
"},{"location":"explanation/archives/#comptes-rendus","title":"Comptes-rendus","text":"Rapport Skia
"},{"location":"explanation/archives/#skia-et-loj","title":"Skia et LoJ","text":"Rapport Skia+LoJ
"},{"location":"explanation/archives/#sli","title":"Sli","text":"Cette page traite des conventions utilis\u00e9es dans le d\u00e9veloppement du site.
"},{"location":"explanation/conventions/#langue","title":"Langue","text":"Les noms, de fonctions, de classe, de fichiers et de dossiers sont en anglais. De m\u00eame, les commentaires et les docstrings sont r\u00e9dig\u00e9s en anglais.
En revanche la documentation est r\u00e9dig\u00e9e en fran\u00e7ais. En effet, les d\u00e9veloppeurs et les d\u00e9veloppeuses qui ont \u00e9t\u00e9, sont et seront amen\u00e9s \u00e0 travailler sur le site sont presque tous des francophones. Or, la bonne compr\u00e9hension prime. Une documentation, qui se doit d'utiliser au mieux les mots justes, compris de mani\u00e8re juste, gagne \u00e0 \u00eatre \u00e9crite en langue vernaculaire, lorsqu'on est assur\u00e9 qu'une coop\u00e9ration internationale est peu probable.
De la sorte, on s'assure au mieux que les r\u00e9dacteurs et r\u00e9dactrices s'expriment bien et que, r\u00e9ciproquement, les lecteurs et lectrices comprennent au mieux.
A ce titre, on ne vous en voudra pas si vous r\u00e9digez des commentaires ou des docstrings en fran\u00e7ais.
En revanche, le code en lui-m\u00eame doit rester imp\u00e9rativement en anglais ; les instructions \u00e9tant en langue anglaise, introduire des mots fran\u00e7ais au milieu cr\u00e9e un contraste qui nuit \u00e0 la compr\u00e9hension.
De mani\u00e8re g\u00e9n\u00e9rale, demandez-vous juste \u00e0 qui vous \u00eates en train d'\u00e9crire :
Le projet utilise Git pour g\u00e9rer les versions et GitHub pour h\u00e9berger le d\u00e9p\u00f4t distant.
L'arbre poss\u00e8de deux branches prot\u00e9g\u00e9es : master
et taiste
.
master
est la branche contenant le code tel qu'il tourne effectivement sur le vrai site de l'AE. Celle-ci doit, autant que faire se peut, rester impeccable.
taiste
est la branche de r\u00e9f\u00e9rence pour le d\u00e9veloppement. Cette derni\u00e8re est r\u00e9guli\u00e8rement d\u00e9ploy\u00e9e sur le site de test. Elle permet de s'assurer que les diverses modifications fonctionnent bien entre elles et fonctionnent bien sur le serveur, avant d'\u00eatre envoy\u00e9es sur master.
Toutes les modifications appliqu\u00e9es sur taiste
doivent se faire via des Pull Requests depuis les diff\u00e9rentes branches de d\u00e9veloppement. Toutes les modifications appliqu\u00e9es sur master
doivent se faire via des Pull Requests depuis taiste
, ou bien depuis une branche de hotfix, dans le cas o\u00f9 il faut r\u00e9parer un bug urgent apparu de mani\u00e8re impromptue.
Aucun push
direct n'est admis, ni sur l'une, ni sur l'autre branche.
En obligeant \u00e0 passer par des PR, on s'assure qu'au moins une autre personne aura lu votre code et que les outils de test et de v\u00e9rification de code auront valid\u00e9 vos modifications.
Par extension du mode de travail par PR, les branches master
et taiste
ne peuvent recevoir du code que sous la forme de merge commits.
De plus, ces branches doivent recevoir, mais jamais donner (\u00e0 part entre elles). Lorsqu'une modification a \u00e9t\u00e9 effectu\u00e9e sur taiste
et que vous souhaitez la r\u00e9cup\u00e9rer dans une de vos branches, vous devez proc\u00e9der par rebase
, et non par merge
.
En d'autres termes, vous devez respecter les deux r\u00e8gles suivantes :
master
et taiste
ne doivent contenir que des merge commitsmaster
et taiste
peuvent contenir des merge commitsgitGraph:\n commit id: \"initial commit\"\n branch bar\n checkout main\n checkout bar\n commit id: \"baz\"\n checkout main\n merge bar id: \"Merge branch bar\"\n branch foo\n commit id: \"foo a\"\n commit id: \"foo b\"\n commit id: \"foo c\"\n checkout main\n merge foo id: \"Merge branch foo\"
gitGraph:\n commit\n branch bar\n branch foo\n commit id: \"foo a\"\n commit id: \"foo b\"\n checkout main\n checkout bar\n commit id: \"baz\"\n checkout main\n merge bar id: \"Merge branch bar\"\n checkout foo\n merge main id: \"Merge branch main\"\n commit id: \"foo c\"\n checkout main\n merge foo id: \"Merge branch foo\"
"},{"location":"explanation/conventions/#style-de-code","title":"Style de code","text":""},{"location":"explanation/conventions/#conventions-de-nommage","title":"Conventions de nommage","text":"Les conventions de nommage sont celles de la PEP8 :
class SacredGraal
)FAVOURITE_COLOUR = \"blue\"
)swallow_origin = \"african\"
)En parall\u00e8le de la casse, les r\u00e8gles de formatage du code sont celles du formateur Ruff. Ces r\u00e8gles sont automatiquement appliqu\u00e9es quand vous faites tourner Ruff, donc vous n'avez pas \u00e0 trop vous poser de questions de ce c\u00f4t\u00e9-l\u00e0.
En ce qui concerne les templates Jinja et les fichiers SCSS, la norme de formatage est celle par d\u00e9faut de djHTML
.
Pour Javascript, nous utilisons biome. C'est \u00e0 la fois un formateur et un linter avec tr\u00e8s peu de configuration, un peu comme ruff.
Le javascript dans les templates jinja
Biome n'est pas capable de lire dans les fichiers jinja, c'est sa principale limitation.
Il est donc recommand\u00e9 d'\u00e9viter de mettre trop de Javascript directement dans jinja mais de pr\u00e9f\u00e9rer des fichiers d\u00e9di\u00e9s.
"},{"location":"explanation/conventions/#qualite-du-code","title":"Qualit\u00e9 du code","text":"Pour s'assurer de la qualit\u00e9 du code, Ruff et Biome sont \u00e9galement utilis\u00e9s.
Tout comme pour le format, Ruff et Biome doivent tourner avant chaque commit.
to edit or not to edit
Vous constaterez sans doute que ruff format
modifie votre code, mais que ruff check
vous signale juste une liste d'erreurs sans rien modifier.
En effet, ruff format
ne s'occupe que de la forme du code, alors que ruff check
regarde la logique du code. Si Ruff modifiait automatiquement la logique du code, \u00e7a serait un coup \u00e0 introduire plus de bugs que \u00e7a n'en r\u00e9soud.
Il existe cependant certaines cat\u00e9gories d'erreurs que Ruff peut r\u00e9parer de mani\u00e8re s\u00fbre. Pour appliquer ces r\u00e9parations, faites :
ruff check --fix\n
Biome se comporte d'une mani\u00e8re tr\u00e8s similaire
npx @biomejs/biome check # Liste toutes les erreurs et leurs cat\u00e9gories\nnpx @biomejs/biome check --write # Applique tous les fix consid\u00e9r\u00e9s safe et formate le code\n
"},{"location":"explanation/conventions/#documentation","title":"Documentation","text":"La documentation est \u00e9crite en markdown, avec les fonctionnalit\u00e9s offertes par MkDocs, MkDocs-material et leurs extensions.
La documentation est int\u00e9gralement en fran\u00e7ais, \u00e0 l'exception des exemples, qui suivent les conventions donn\u00e9es plus haut.
"},{"location":"explanation/conventions/#decoupage","title":"D\u00e9coupage","text":"La s\u00e9paration entre les diff\u00e9rentes parties de la documentation se fait en suivant la m\u00e9thodologie Diataxis. On compte quatre sections :
Pour plus de d\u00e9tails, lisez directement la documentation de Diataxis, qui expose ces concepts de mani\u00e8re beaucoup plus compl\u00e8te.
"},{"location":"explanation/conventions/#style","title":"Style","text":"Votre markdown doit \u00eatre compos\u00e9 de lignes courtes ; \u00e0 partir de 88 caract\u00e8res, c'est trop long. Si une phrase est trop longue pour tenir sur une ligne, vous pouvez l'\u00e9crire sur plusieurs.
Une ligne ne peut pas contenir plus d'une seule phrase. Dit autrement, quand vous finissez une phrase, faites syst\u00e9matiquement un saut de ligne.
Bien \u2714\ufe0fPas bien \u274cFirst shalt thou take out the Holy Pin,\nthen shalt thou count to three, no more, no less.\nThree shalt be the number thou shalt count,\nand the number of the counting shalt be three.\nFour shalt thou not count, neither count thou two,\nexcepting that thou then proceed to three.\nFive is right out.\nOnce the number three, being the third number, be reached,\nthen lobbest thou thy Holy Hand Grenade of Antioch towards thou foe,\nwho being naughty in My sight, shall snuff it.\n
First shalt thou take out the Holy Pin, then shalt thou count to three, no more, no less. Three shalt be the number thou shalt count, and the number of the counting shalt be three. Four shalt thou not count, neither count thou two, excepting that thou then proceed to three. Five is right out. Once the number three, being the third number, be reached, then lobbest thou thy Holy Hand Grenade of Antioch towards thou foe, who being naughty in My sight, shall snuff it.\n
\u00c0 noter que ces deux exemples donnent le m\u00eame r\u00e9sultat dans la documentation g\u00e9n\u00e9r\u00e9e. Mais la version avec de courtes lignes est beaucoup plus facile \u00e0 modifier et \u00e0 versioner.
Grammaire et orthographe
Ca peut paraitre \u00e9vident dit comme \u00e7a, mais c'est toujours bon \u00e0 rappeler : \u00e9vitez de faire des fautes de fran\u00e7ais. Relisez vous quand vous avez fini d'\u00e9crire.
"},{"location":"explanation/conventions/#docstrings","title":"Docstrings","text":"Les docstrings sont \u00e9crits en suivant la norme Google et les fonctionnalit\u00e9s de Griffe.
Ils doivent \u00eatre explicites sur ce que la fonction accomplit, mais ne pas parler de comment elle le fait. Un bon docstring est celui qui dit exactement ce qu'il faut pour qu'on puisse savoir comment utiliser la fonction ou la classe document\u00e9e sans avoir \u00e0 lire son code.
Tout comme les p\u00e9dales d'une voiture : pour pouvoir conduire, vous avez juste besoin de savoir ce qui se passe quand vous appuyez dessus. La connaissance de la m\u00e9canique interne est inutile dans ce cadre.
N'h\u00e9sitez pas \u00e0 mettre des examples dans vos docstrings.
"},{"location":"explanation/conventions/#pourquoi-une-partie-du-projet-ne-respecte-pas-ces-conventions","title":"Pourquoi une partie du projet ne respecte pas ces conventions ?","text":"Parce que le projet est vieux. Le commit initial date du 18 novembre 2015. C'\u00e9tait il y a presque dix ans au moment o\u00f9 ces lignes sont \u00e9crites. Au d\u00e9but, on ne se posait pas forc\u00e9ment ce genre de questions. Puis le projet a grandi, de mani\u00e8re s\u00e9dimentaire, fonctionnalit\u00e9 apr\u00e8s fonctionnalit\u00e9, d\u00e9velopp\u00e9 par des personnes n'ayant pas toutes la m\u00eame esth\u00e9tique.
On retrouve dans le code ces inspirations diverses de personnes vari\u00e9es \u00e0 travers une d\u00e9cennie. Au bout d'un moment, il est bon de se poser et de normaliser les choses.
De ce c\u00f4t\u00e9-l\u00e0, une premi\u00e8re pierre a \u00e9t\u00e9 pos\u00e9e en novembre 2018, avec l'utilisation d'un formateur. Il convient de poursuivre ce travail d'unification.
Cependant, l\u00e0 o\u00f9 on peut reformater automatiquement du code, il faut y aller \u00e0 la main pour retravailler un style de code. C'est un travail de fourmi qui prendra du temps.
"},{"location":"explanation/technos/","title":"Technologies utilis\u00e9es","text":"Bien choisir ses technologies est crucial puisqu'une fois que le projet est suffisamment avanc\u00e9, il est tr\u00e8s difficile voir impossible de revenir en arri\u00e8re.
En novembre 2015, plusieurs choix se pr\u00e9sentaient :
Le PHP 5, bient\u00f4t 7, de l'\u00e9poque \u00e9tant assez discutable comme cet article le montre, et l'ancien site ayant laiss\u00e9 un go\u00fbt amer \u00e0 certains d\u00e9veloppeurs, celui-ci a \u00e9t\u00e9 mis de c\u00f4t\u00e9.
L'\u00e9cosyst\u00e8me Javascript \u00e9tant \u00e0 peine naissant et les frameworks allant et venant en seulement quelques mois, il \u00e9tait impossible de pr\u00e9dire avec certitude si ceux-ci passeraient l'\u00e9preuve du temps, il \u00e9tait inconcevable de tout parier l\u00e0-dessus.
Ne restait plus que le Python et le Ruby avec les frameworks Django et Ruby On Rails. Ruby ayant une r\u00e9putation d'\u00eatre tr\u00e8s \"cutting edge\", c'est Python, un langage bien implant\u00e9 et ayant fait ses preuves, qui a \u00e9t\u00e9 retenu.
Il est \u00e0 noter que r\u00e9\u00e9crire le site avec un framework PHP comme Laravel ou Symphony eut aussi \u00e9t\u00e9 possible, ces deux technologies \u00e9tant assez matures et robustes au moment o\u00f9 le d\u00e9veloppement a commenc\u00e9. Cependant, il aurait \u00e9t\u00e9 potentiellement fastidieux de maintenir en parall\u00e8le deux versions de PHP sur le serveur durant toute la dur\u00e9e du d\u00e9veloppement. Il faut aussi prendre en compte que nous \u00e9tions \u00e0 ce moment d\u00e9go\u00fbt\u00e9s du PHP.
"},{"location":"explanation/technos/#backend","title":"Backend","text":""},{"location":"explanation/technos/#python-3","title":"Python 3","text":"Site officiel
Le python est un langage de programmation interpr\u00e9t\u00e9 multi paradigme sorti en 1991. Il est tr\u00e8s populaire dans de nombreux domaines pour sa simplicit\u00e9 d'utilisation, sa polyvalence, sa s\u00e9curit\u00e9 ainsi que sa grande communaut\u00e9 de d\u00e9veloppeur. Sa version 3, non r\u00e9tro compatible avec sa version 2, a \u00e9t\u00e9 publi\u00e9e en 2008.
Note
Puisque toutes les d\u00e9pendances du backend sont des packages Python, elles sont toutes ajout\u00e9es directement dans le fichier pyproject.toml, \u00e0 la racine du projet.
"},{"location":"explanation/technos/#django","title":"Django","text":"Site officiel
Documentation
Django est un framework web pour Python apparu en 2005. Il fournit un grand nombre de fonctionnalit\u00e9s pour d\u00e9velopper un site rapidement et simplement. Cela inclut entre autre un serveur Web de d\u00e9veloppement, un parseur d'URLs pour le routage, un ORM (Object-Relational Mapper) pour la gestion de la base de donn\u00e9e, un cadre pour l'\u00e9criture et l'ex\u00e9cution des tests, de nombreux utilitaires pour l'internationalisation, les zones horaires et autres, une interface web d'administration ais\u00e9ment configurable, un moteur de templates pour le rendu HTML...
Django propose une version LTS (Long Term Support) qui reste stable et est maintenu sur des cycles plus longs. Ce sont ces versions qui sont utilis\u00e9es.
"},{"location":"explanation/technos/#postgresql-sqlite3","title":"PostgreSQL / SQLite3","text":"Site officiel PostgreSQL
Site officiel SQLite
Comme la majorit\u00e9 des sites internet, le Sith de l'AE enregistre ses donn\u00e9es dans une base de donn\u00e9es. Nous utilisons une base de donn\u00e9e relationnelle puisque c'est la mani\u00e8re typique d'utiliser Django et c'est ce qu'utilise son ORM.
Le principal \u00e0 retenir ici est :
sqlite
est incluse dans les librairies par d\u00e9faut de Python). Certaines instructions ne sont pas support\u00e9es par cette technologie et il est parfois n\u00e9cessaire d'installer PostgreSQL pour le d\u00e9veloppement de certaines parties du site (cependant, ces parties sont rares, et vous pourriez m\u00eame ne jamais en rencontrer une).PostgreSQL est-ce avec quoi le site doit fonctionner. Cependant, pour permettre aux d\u00e9veloppeurs de travailler en installant le moins de d\u00e9pendances possible sur leur ordinateur, il est \u00e9galement d\u00e9sirable de chercher la compatibilit\u00e9 avec SQLite. Aussi, dans la mesure du possible, nous cherchons \u00e0 ce que les interactions avec la base de donn\u00e9es fonctionnent avec l'un comme avec l'autre.
Heureusement, et gr\u00e2ce \u00e0 l'ORM de Django, cette double compatibilit\u00e9 est presque toujours possible.
"},{"location":"explanation/technos/#frontend","title":"Frontend","text":""},{"location":"explanation/technos/#jinja2","title":"Jinja2","text":"Site officiel
Jinja2 est un moteur de template \u00e9crit en Python qui s'inspire fortement de la syntaxe des templates de Django. Ce moteur apporte toutefois son lot d'am\u00e9liorations non n\u00e9gligeables. Il permet par exemple l'ajout de macros, sortes de fonctions \u00e9crivant du HTML.
Un moteur de templates permet de g\u00e9n\u00e9rer du contenu textuel de mani\u00e8re proc\u00e9dural en fonction des donn\u00e9es \u00e0 afficher, cela permet de pouvoir inclure du code proche du Python dans la syntaxe au milieu d'un document contenant principalement du HTML. On peut facilement faire des boucles ou des conditions ainsi m\u00eame que de l'h\u00e9ritage de templates.
Note
le rendu est fait c\u00f4t\u00e9 serveur, si on souhaite faire des modifications c\u00f4t\u00e9 client, il faut utiliser du Javascript, rien ne change \u00e0 ce niveau-l\u00e0.
"},{"location":"explanation/technos/#jquery","title":"jQuery","text":"Site officiel
jQuery est une biblioth\u00e8que JavaScript libre et multiplateforme cr\u00e9\u00e9e pour faciliter l'\u00e9criture de scripts c\u00f4t\u00e9 client dans le code HTML des pages web. La premi\u00e8re version est lanc\u00e9e en janvier 2006 par John Resig.
C'est une vieille technologie et certains feront remarquer \u00e0 juste titre que le Javascript moderne permet d'utiliser assez simplement la majorit\u00e9 de ce que fournit jQuery sans rien avoir \u00e0 installer. Cependant, de nombreuses d\u00e9pendances du projet utilisent encore jQuery qui est toujours tr\u00e8s implant\u00e9 aujourd'hui. Le sucre syntaxique qu'offre cette librairie reste tr\u00e8s agr\u00e9able \u00e0 utiliser et \u00e9conomise parfois beaucoup de temps. \u00c7a fonctionne et \u00e7a fonctionne tr\u00e8s bien. C'est maintenu et pratique.
"},{"location":"explanation/technos/#alpinejs","title":"AlpineJS","text":"Site officiel
AlpineJS est une librairie l\u00e9g\u00e8re et minimaliste permettant le rendu dynamique d'\u00e9l\u00e9ments sur une page web, code de mani\u00e8re d\u00e9clarative. La librairie est d\u00e9crite par ses cr\u00e9ateurs comme : \"un outil robuste et minimal pour composer un comportement directement dans vos balises\".
Alpine permet d'accomplir la plupart du temps le m\u00eame r\u00e9sultat qu'un usage des fonctionnalit\u00e9s de base des plus gros frameworks Javascript, mais est beaucoup plus l\u00e9ger, un peu plus facile \u00e0 prendre en main et ne s'embarrasse pas d'un DOM virtuel. Gr\u00e2ce \u00e0 son architecture, il est extr\u00eamement bien adapt\u00e9 pour un usage dans un site multipage. C'est une technologie simple et puissante qui se veut comme le jQuery du web moderne.
"},{"location":"explanation/technos/#sass","title":"Sass","text":"Site officiel
Sass (Syntactically Awesome Stylesheets) est un langage dynamique de g\u00e9n\u00e9ration de feuilles CSS apparu en 2006. C'est un langage de CSS \"am\u00e9lior\u00e9\" qui permet l'ajout de variables (\u00e0 une \u00e9poque o\u00f9 le CSS ne les supportait pas), de fonctions, mixins ainsi qu'une syntaxe pour imbriquer plus facilement et proprement les r\u00e8gles sur certains \u00e9l\u00e9ments. Le Sass est traduit en CSS directement c\u00f4t\u00e9 serveur et le client ne re\u00e7oit que du CSS.
C'est une technologie stable, mature et pratique qui ne n\u00e9cessite pas \u00e9norm\u00e9ment d'apprentissage.
"},{"location":"explanation/technos/#fontawesome","title":"Fontawesome","text":"Site officiel
Fontawesome regroupe tout un ensemble d'ic\u00f4nes libres de droits utilisables facilement sur n'importe quelle page web. Ils sont simples \u00e0 modifier puisque modifiables via le CSS et pr\u00e9sentent l'avantage de fonctionner sur tous les navigateurs contrairement \u00e0 un simple ic\u00f4ne unicode qui s'affiche lui diff\u00e9remment selon la plate-forme.
Note
C'est une d\u00e9pendance capricieuse qui \u00e9volue tr\u00e8s vite et qu'il faut tr\u00e8s souvent mettre \u00e0 jour.
Warning
Il a \u00e9t\u00e9 d\u00e9cid\u00e9 de ne pas utiliser de CDN puisque le site ralentissait r\u00e9guli\u00e8rement. Il est pr\u00e9f\u00e9rable de fournir cette d\u00e9pendance avec le site.
"},{"location":"explanation/technos/#workflow","title":"Workflow","text":""},{"location":"explanation/technos/#git","title":"Git","text":"Site officiel
Git est un logiciel de gestion de versions \u00e9crit par Linus Torvalds pour les besoins du noyau linux en 2005. C'est ce logiciel qui remplace svn anciennement utilis\u00e9 pour g\u00e9rer les sources du projet (rappelez vous, l'ancien site date d'avant 2005). Git est plus complexe \u00e0 utiliser, mais est bien plus puissant, permet de g\u00e9rer plusieurs versions en parall\u00e8le et g\u00e9n\u00e8re des codebases vraiment plus l\u00e9g\u00e8res puisque seules les modifications sont enregistr\u00e9es (contrairement \u00e0 svn qui garde une copie de la codebase par version).
Git s'\u00e9tant impos\u00e9 comme le principal outil de gestion de versions, sa communaut\u00e9 est tr\u00e8s grande et sa documentation tr\u00e8s fournie. Il est \u00e9galement ais\u00e9 de trouver des outils avec une interface graphique, qui simplifient grandement son usage.
"},{"location":"explanation/technos/#github","title":"GitHub","text":"Site officiel
Page github du P\u00f4le Informatique de l'AE
Github est un service web d'h\u00e9bergement et de gestion de d\u00e9veloppement de logiciel. C'est une plate-forme avec interface web permettant de d\u00e9poser du code g\u00e9r\u00e9 avec Git offrant \u00e9galement de l'int\u00e9gration continue et du d\u00e9ploiement automatique. C'est au travers de cette plate-forme que le Sith de l'AE est g\u00e9r\u00e9.
"},{"location":"explanation/technos/#sentry","title":"Sentry","text":"Site officiel
Instance de l'AE
Sentry est une plate-forme libre qui permet de se tenir inform\u00e9 des bugs qui ont lieu sur le site. \u00c0 chaque crash du logiciel (erreur 500), une erreur est envoy\u00e9e sur la plate-forme et il est indiqu\u00e9 pr\u00e9cis\u00e9ment \u00e0 quelle ligne de code celle-ci a eu lieu, \u00e0 quelle heure, combien de fois, avec quel navigateur la page a \u00e9t\u00e9 visit\u00e9e et m\u00eame \u00e9ventuellement un commentaire de l'utilisateur qui a rencontr\u00e9 le bug.
C'est un outil incroyablement pratique pour savoir tout ce qui ne fonctionne pas, et surtout pour r\u00e9colter toutes les informations n\u00e9cessaires \u00e0 la r\u00e9paration des bugs.
"},{"location":"explanation/technos/#poetry","title":"Poetry","text":"Utiliser Poetry
Poetry est un utilitaire qui permet de cr\u00e9er et g\u00e9rer des environnements Python de mani\u00e8re simple et intuitive. Il permet \u00e9galement de g\u00e9rer et mettre \u00e0 jour le fichier de d\u00e9pendances.
L'avantage d'utiliser poetry (et les environnements virtuels en g\u00e9n\u00e9ral) est de pouvoir g\u00e9rer plusieurs projets diff\u00e9rents en parall\u00e8le puisqu'il permet d'avoir sur sa machine plusieurs environnements diff\u00e9rents et donc plusieurs versions d'une m\u00eame d\u00e9pendance dans plusieurs projets diff\u00e9rents sans impacter le syst\u00e8me sur lequel le tout est install\u00e9.
Poetry poss\u00e8de \u00e9galement l'avantage par rapport \u00e0 un simple venv que les versions exactes de toutes les d\u00e9pendances, y compris celles utilis\u00e9es par d'autres d\u00e9pendances, sont consign\u00e9es dans un fichier .lock
. On est donc s\u00fbr et certain que deux environnements virtuels configur\u00e9s avec le m\u00eame fichier lock utiliseront exactement les m\u00eames versions des m\u00eames d\u00e9pendances, y compris si celles-ci ne sont pas indiqu\u00e9es explicitement.
Les d\u00e9pendances utilis\u00e9es par poetry sont d\u00e9clar\u00e9es dans le fichier pyproject.toml
, situ\u00e9 \u00e0 la racine du projet.
Site officiel
Pour faciliter la lecture du code, il est toujours appr\u00e9ciable d'avoir une norme d'\u00e9criture coh\u00e9rente. C'est g\u00e9n\u00e9ralement \u00e0 l'\u00e9tape de relecture des modifications par les autres contributeurs que sont rep\u00e9r\u00e9es ces fautes de normes qui se doivent d'\u00eatre corrig\u00e9es pour le bien commun.
Imposer une norme est tr\u00e8s fastidieux, que ce soit pour ceux qui relisent ou pour ceux qui \u00e9crivent. C'est pour cela que nous utilisons Ruff, qui est un formateur et linter automatique de code. Une fois l'outil lanc\u00e9, il parcourt la codebase pour y rep\u00e9rer les fautes de norme et les erreurs de logique courantes et les corrige automatiquement (quand c'est possible) sans que l'utilisateur ait \u00e0 s'en soucier. Bien install\u00e9, il peut effectuer ce travail \u00e0 chaque sauvegarde d'un fichier dans son \u00e9diteur, ce qui est tr\u00e8s agr\u00e9able pour travailler.
"},{"location":"explanation/technos/#biome","title":"Biome","text":"Site officiel
Puisque Ruff ne fonctionne malheureusement que pour le Python, nous utilisons Biome pour le javascript.
Biome est \u00e9galement capable d'analyser et formater les fichiers json et css.
Tout comme Ruff, Biome fait office de formateur et de linter.
"},{"location":"explanation/technos/#djhtml","title":"DjHTML","text":"Site officiel
Ruff permet de formater les fichiers Python et Biome les fichiers js, mais ils ne formattent pas les templates et les feuilles de style. Pour \u00e7a, il faut un autre outil, ais\u00e9ment int\u00e9grable dans la CI : djHTML
.
En utilisant conjointement Ruff, Biome et djHTML, on arrive donc \u00e0 la fois \u00e0 formater les fichiers Python et les fichiers relatifs au frontend.
"},{"location":"explanation/technos/#npm","title":"Npm","text":"Utiliser npm
Npm est un gestionnaire de paquets pour Node.js. C'est l'un des gestionnaires les plus r\u00e9pandus sur le march\u00e9 et est tr\u00e8s complet et utilis\u00e9.
Npm poss\u00e8de, tout comme Poetry, la capacit\u00e9 de locker les d\u00e9pendances au moyen d'un fichier .lock
. Il a \u00e9galement l'avantage de presque toujours \u00eatre facilement disponible \u00e0 l'installation.
Nous l'utilisons ici pour g\u00e9rer les d\u00e9pendances JavaScript. Celle-ci sont d\u00e9clar\u00e9es dans le fichier package.json
situ\u00e9 \u00e0 la racine du projet.
Utiliser webpack
Webpack est un bundler de fichiers static. Il nous sert ici \u00e0 mettre \u00e0 disposition les d\u00e9pendances frontend g\u00e9r\u00e9es par npm.
Il sert \u00e9galement \u00e0 int\u00e9grer les autres outils JavaScript au workflow du Sith de mani\u00e8re transparente.
Webpack a \u00e9t\u00e9 choisi pour sa versatilit\u00e9 et sa popularit\u00e9. C'est un des plus anciens bundler et il est l\u00e0 pour rester.
Le logiciel se configure au moyen du fichier webpack.config.js
\u00e0 la racine du projet.
Babel
Babel est un outil qui offre la promesse de convertir le code JavaScript moderne en code JavaScript plus ancien sans action de la part du d\u00e9veloppeur. Il permet de ne pas se soucier de la compatibilit\u00e9 avec les navigateurs et de coder comme si on \u00e9tait toujours sur la derni\u00e8re version du langage.
Babel est int\u00e9gr\u00e9 dans Webpack et tout code bundl\u00e9 par celui-ci est automatiquement converti.
Le logiciel se configure au moyen du fichier babel.config.json
\u00e0 la racine du projet.
Pour \u00e9viter d'avoir \u00e0 sourcer l'environnement \u00e0 chaque fois qu'on rentre dans le projet, il est possible d'utiliser l'utilitaire direnv.
Comme pour beaucoup de choses, il faut commencer par l'installer :
LinuxmacOS Debian/UbuntuArch Linuxsudo apt install direnv\n
sudo pacman -S direnv\n
brew install direnv\n
Puis on configure :
bashzshnuecho 'eval \"$(direnv hook bash)\"' >> ~/.bashrc\nexit # On red\u00e9marre le terminal\n
echo 'eval \"$(direnv hook zsh)\"' >> ~/.zshrc\nexit # On red\u00e9marre le terminal\n
D\u00e9sol\u00e9, par direnv hook
pour nu
Une fois le terminal red\u00e9marr\u00e9, dans le r\u00e9pertoire du projet :
direnv allow .\n
Une fois que cette configuration a \u00e9t\u00e9 appliqu\u00e9e, aller dans le dossier du site applique automatiquement l'environnement virtuel. \u00c7a peut faire gagner pas mal de temps.
Direnv est un utilitaire tr\u00e8s puissant et qui peut s'av\u00e9rer pratique dans bien des situations, n'h\u00e9sitez pas \u00e0 aller vous renseigner plus en d\u00e9tail sur celui-ci.
"},{"location":"howto/js-import-paths/","title":"Ajouter un chemin d'import javascript","text":"Vous avez ajout\u00e9 une application et vous voulez y mettre du javascript ?
Vous voulez importer depuis cette nouvelle application dans votre script g\u00e9r\u00e9 par webpack ?
Eh bien il faut manuellement enregistrer dans node o\u00f9 les trouver et c'est tr\u00e8s simple.
D'abord, il faut ajouter dans node via package.json
:
{\n // ...\n \"imports\": {\n // ...\n \"#mon_app:*\": \"./mon_app/static/webpack/*\"\n }\n // ...\n}\n
Ensuite, pour faire fonctionne l'auto-compl\u00e9tion, il faut configurer tsconfig.json
:
{\n \"compilerOptions\": {\n // ...\n \"paths\": {\n // ...\n \"#mon_app:*\": [\"./mon_app/static/webpack/*\"]\n }\n }\n}\n
Et c'est tout !
Note
Il se peut qu'il soit n\u00e9cessaire de red\u00e9marrer ./manage.py runserver
pour que les changements prennent effet.
Une fois par an, il est g\u00e9n\u00e9ralement n\u00e9cessaire d'ajouter le nouveau logo d'une promo. C'est un processus manuel.
Automatisation
Cr\u00e9er une interface automatique sur le site serait compliqu\u00e9 et long \u00e0 faire. Les erreurs de format des utilisateurs sont g\u00e9n\u00e9ralement nombreuses et on se retrouverais souvent avec des logos ratatin\u00e9s. Il est donc plus simple et plus fiable de faire cette op\u00e9ration manuellement, \u00e7a prend quelques minutes et on est certain de la qualit\u00e9 \u00e0 la fin.
Les logos de promo sont \u00e0 manuellement ajouter dans le projet. Ils se situent dans le dossier core/static/core/img/
.
Leur format est le suivant:
promo_xx.png
Une migration est un fichier Python qui contient des instructions pour modifier la base de donn\u00e9es. Une base de donn\u00e9es \u00e9volue au cours du temps, et les migrations permettent de garder une trace de ces modifications.
Gr\u00e2ce \u00e0 elles, on peut \u00e9galement apporter des modifications \u00e0 la base de donn\u00e9es sans \u00eatre oblig\u00e9es de la recr\u00e9er. On applique seulement les modifications n\u00e9cessaires.
"},{"location":"howto/migrations/#appliquer-les-migrations","title":"Appliquer les migrations","text":"Pour appliquer les migrations, ex\u00e9cutez la commande suivante :
python ./manage.py migrate\n
Vous remarquerez peut-\u00eatre que cette commande a \u00e9t\u00e9 utilis\u00e9e dans la section Installation. En effet, en partant d'une base de donn\u00e9es vierge et en appliquant toutes les migrations, on arrive \u00e0 l'\u00e9tat actuel de la base de donn\u00e9es. Logique.
Si vous utilisez cette commande sur une base de donn\u00e9es sur laquelle toutes les migrations ont \u00e9t\u00e9 appliqu\u00e9es, elle ne fera rien.
Si vous utilisez cette commande sur une base de donn\u00e9es sur laquelle seule une partie des migrations ont \u00e9t\u00e9 appliqu\u00e9es, seules les migrations manquantes seront appliqu\u00e9es.
"},{"location":"howto/migrations/#creer-une-migration","title":"Cr\u00e9er une migration","text":"Pour cr\u00e9er une migration, ex\u00e9cutez la commande suivante :
python ./manage.py makemigrations\n
Cette commande comparera automatiquement le contenu des classes de mod\u00e8les et le comparera avec les migrations d\u00e9j\u00e0 appliqu\u00e9es. A partir de cette comparaison, elle g\u00e9n\u00e9rera automatiquement une nouvelle migration.
Note
La commande makemigrations
ne fait que g\u00e9n\u00e9rer les fichiers de migration. Elle ne modifie pas la base de donn\u00e9es. Pour appliquer la migration, n'oubliez pas la commande migrate
.
Un fichier de migration ressemble \u00e0 \u00e7a :
from django.db import migrations\n\n\nclass Migration(migrations.Migration):\n\n dependencies = [\n # liste des autres migrations \u00e0 appliquer avant celle-ci\n ]\n\n operations = [\n # liste des op\u00e9rations \u00e0 appliquer sur la db\n ]\n
Gr\u00e2ce \u00e0 la liste des d\u00e9pendances, Django sait dans quel ordre les migrations doivent \u00eatre appliqu\u00e9es. Gr\u00e2ce \u00e0 la liste des op\u00e9rations, Django sait quelles sont les op\u00e9rations \u00e0 appliquer durant cette migration.
"},{"location":"howto/migrations/#revenir-a-une-migration-anterieure","title":"Revenir \u00e0 une migration ant\u00e9rieure","text":"Lorsque vous d\u00e9veloppez, il peut arriver que vous vouliez revenir \u00e0 une migration ant\u00e9rieure. Pour cela, il suffit d'appliquer la commande migrate
en sp\u00e9cifiant le nom de la migration \u00e0 laquelle vous voulez revenir :
python ./manage.py migrate <application> <num\u00e9ro de la migration>\n
Par exemple, si vous voulez revenir \u00e0 la migration 0001_initial
de l'application customer
, vous pouvez ex\u00e9cuter la commande suivante :
python ./manage.py migrate customer 0001\n
"},{"location":"howto/migrations/#customiser-une-migration","title":"Customiser une migration","text":"Il peut arriver que vous ayez besoin de modifier le fichier de migration g\u00e9n\u00e9r\u00e9 par Django. Par exemple, si vous voulez ex\u00e9cuter un script Python lors de l'application de la migration.
Dans ce cas, vous pouvez trouver les fichiers de migration dans le dossier migrations
de chaque application. Vous pouvez modifier le fichier Python correspondant \u00e0 la migration que vous voulez modifier.
Ajoutez l'op\u00e9ration que vous voulez effectuer dans l'attribut operations
de la classe Migration
.
Par exemple :
from django.db import migrations\n\ndef forwards_func(apps, schema_editor):\n print(\"Appplication de la migration\")\n\ndef reverse_func(apps, schema_editor):\n print(\"Annulation de la migration\")\n\nclass Migration(migrations.Migration):\n\n dependencies = []\n\n operations = [\n migrations.RunPython(forwards_func, reverse_func),\n ]\n
Script d'annulation de la migration
Lorsque vous incluez un script Python dans une migration, incluez toujours aussi un script d'annulation, sinon Django ne pourra pas annuler la migration apr\u00e8s son application.
Vous ne pourrez donc pas revenir \u00e0 un \u00e9tat ant\u00e9rieur de la db, \u00e0 moins de la recr\u00e9er de z\u00e9ro.
"},{"location":"howto/migrations/#fusionner-des-migrations","title":"Fusionner des migrations","text":"Quand on travaille sur une fonctionnalit\u00e9 qui n\u00e9cessite une modification de la base de donn\u00e9es, les fichiers de migration sont comme toute chose : on peut se rendre compte que les changements apport\u00e9s pourraient \u00eatre meilleurs.
Par exemple, supposons que nous voulons cr\u00e9er un mod\u00e8le repr\u00e9sentant une UE suivie par un \u00e9tudiant (ne demandez pas pourquoi on voudrait faire \u00e7a, c'est juste pour l'exemple). Un tel mod\u00e8le aurait besoin des informations suivantes :
On \u00e9crirait donc, dans l'application pedagogy
:
from django.db import models\nfrom core.models import User\n\nclass UserUe(models.Model):\n user = models.ForeignKey(User, on_delete=models.CASCADE)\n ue = models.CharField(max_length=10)\n
Et nous aurions le fichier de migration suivant :
from django.db import migrations, models\nfrom django.conf import settings\n\nclass Migration(migrations.Migration):\n dependencies = [\n migrations.swappable_dependency(settings.AUTH_USER_MODEL),\n (\"pedagogy\", \"0003_alter_uv_language\"),\n ]\n\n operations = [\n migrations.CreateModel(\n name=\"UserUe\",\n fields=[\n (\n \"id\",\n models.AutoField(\n auto_created=True,\n primary_key=True,\n serialize=False,\n verbose_name=\"ID\",\n ),\n ),\n (\"ue\", models.CharField(max_length=10)),\n (\n \"user\",\n models.ForeignKey(\n on_delete=models.deletion.CASCADE,\n to=settings.AUTH_USER_MODEL,\n ),\n ),\n ],\n ),\n ]\n
On finit son travail, on soumet la PR. Mais l\u00e0, quelqu'un fait remarquer qu'il existe d\u00e9j\u00e0 un mod\u00e8le pour repr\u00e9senter une UE. On modifie donc le mod\u00e8le :
from django.db import models\nfrom core.models import User\nfrom pedagogy.models import UV\n\nclass UserUe(models.Model):\n user = models.ForeignKey(User, on_delete=models.CASCADE)\n ue = models.ForeignKey(UV, on_delete=models.CASCADE)\n
On refait la commande makemigrations
et on obtient :
from django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n dependencies = [\n (\"pedagogy\", \"0004_userue\"),\n ]\n\n operations = [\n migrations.AlterField(\n model_name=\"userue\",\n name=\"ue\",\n field=models.ForeignKey(\n on_delete=models.deletion.CASCADE, to=\"pedagogy.uv\"\n ),\n ),\n ]\n
Sauf que maintenant, nous avons deux fichiers de migration, alors qu'en r\u00e9alit\u00e9, on ne souhaite faire qu'une seule migration, une fois qu'on aura exp\u00e9di\u00e9 le code en prod. Certes, \u00e7a fonctionnerait d'appliquer les deux, mais \u00e7a pose un probl\u00e8me d'encombrement.
Plus il y a de fichiers de migrations, plus il y a de migrations \u00e0 r\u00e9soudre au moment de l'installation du projet chez quelqu'un, plus c'est emb\u00eatant \u00e0 g\u00e9rer et plus Django prendra du temps \u00e0 r\u00e9soudre les migrations.
C'est pourquoi il est bon de respecter le principe : une PR = un fichier de migration maximum par application.
Nous voulons donc fusionner les deux, pour n'en garder qu'une. Pour \u00e7a, deux mani\u00e8res de proc\u00e9der :
Pour la m\u00e9thode manuelle, on ne pourrait pas vous dire exhaustivement comment faire. Mais ne vous inqui\u00e9tez pas, ce n'est pas tr\u00e8s dur. Regardez bien quelles sont les instructions utilis\u00e9es par django pour les op\u00e9rations de migrations, et avec un peu d'astuce et quelques copier-coller, vous vous en sortirez comme des chefs.
Pour la m\u00e9thode squashmigrations
, ex\u00e9cutez la commande
python ./manage.py squasmigrations <app> <migration de d\u00e9but (incluse)> <migration de fin (incluse)> \n
Par exemple, dans notre cas, \u00e7a donnera :
python ./manage.py squasmigrations pedagogy 0004 0005 \n
La commande vous donnera ceci :
from django.conf import settings\nfrom django.db import migrations, models\n\nclass Migration(migrations.Migration):\n replaces = [(\"pedagogy\", \"0004_userue\"), (\"pedagogy\", \"0005_alter_userue_ue\")]\n\n dependencies = [\n (\"pedagogy\", \"0003_alter_uv_language\"),\n migrations.swappable_dependency(settings.AUTH_USER_MODEL),\n ]\n\n operations = [\n migrations.CreateModel(\n name=\"UserUe\",\n fields=[\n (\n \"id\",\n models.AutoField(\n auto_created=True,\n primary_key=True,\n serialize=False,\n verbose_name=\"ID\",\n ),\n ),\n (\n \"ue\",\n models.ForeignKey(\n on_delete=models.deletion.CASCADE, to=\"pedagogy.uv\"\n ),\n ),\n (\n \"user\",\n models.ForeignKey(\n on_delete=models.deletion.CASCADE,\n to=settings.AUTH_USER_MODEL,\n ),\n ),\n ],\n ),\n ]\n
Vous pouvez alors supprimer les deux autres fichiers.
Vous remarquerez peut-\u00eatre la pr\u00e9sence de la ligne suivante :
replaces = [(\"pedagogy\", \"0004_userue\"), (\"pedagogy\", \"0005_alter_userue_ue\")]\n
Cela sert \u00e0 dire que cette migration doit \u00eatre appliqu\u00e9e \u00e0 la place des deux autres. Une fois que vous aurez supprim\u00e9 les deux fichiers, supprimez \u00e9galement cette ligne.
Warning
Django sait quelles migrations ont \u00e9t\u00e9 appliqu\u00e9es, en les stockant dans une table de la db. Si une migration est enregistr\u00e9e en db, sans que le fichier de migration correspondant existe, la commande migrate
\u00e9choue.
Quand vous faites un squashmigrations
, pensez donc \u00e0 appliquer la commande migrate
juste apr\u00e8s (mais avant la suppression des anciens fichiers), pour que Django supprime de la base de donn\u00e9es les migrations devenues inutiles.
Pour connecter l'application \u00e0 une instance de sentry (ex: https://sentry.io), il est n\u00e9cessaire de configurer la variable SENTRY_DSN
dans le fichier settings_custom.py
. Cette variable est compos\u00e9e d'un lien complet vers votre projet sentry.
Nous utilisons du SCSS dans le projet. En environnement de d\u00e9veloppement (DEBUG=True
), le SCSS est compil\u00e9 \u00e0 chaque fois que le fichier est demand\u00e9. Pour la production, le projet consid\u00e8re que chacun des fichiers est d\u00e9j\u00e0 compil\u00e9. C'est pourquoi le SCSS est automatiquement compil\u00e9 lors de la r\u00e9cup\u00e9ration des fichiers statiques. Les fichiers JS sont \u00e9galement automatiquement minifi\u00e9s.
Il peut \u00eatre judicieux de supprimer les anciens fichiers statiques avant de collecter les nouveaux. Pour \u00e7a, ajoutez le flag --clear
\u00e0 la commande collectstatic
:
python ./manage.py collectstatic --clear\n
Tip
Le dossier o\u00f9 seront enregistr\u00e9s ces fichiers statiques peut \u00eatre chang\u00e9 en modifiant la variable STATIC_ROOT
dans les param\u00e8tres.
L'ORM de Django est puissant, tr\u00e8s puissant, non par parce qu'il est performant (apr\u00e8s tout, ce n'est qu'une interface, le gros du boulot, c'est la db qui le fait), mais parce qu'il permet d'\u00e9crire de mani\u00e8re relativement simple un grand panel de requ\u00eates.
De mani\u00e8re g\u00e9n\u00e9rale, puisqu'un ORM est un syst\u00e8me consistant \u00e0 manipuler avec un code orient\u00e9-objet une db relationnelle (c'est-\u00e0-dire deux paradigmes qui ne fonctionnent absolument pas pareil), on rencontre un des deux probl\u00e8mes suivants :
select
avec un where
, on s'emm\u00eale les pinceaux et on se dit que \u00e7a aurait \u00e9t\u00e9 plus simple de le faire directement en SQL.Django est dans ce deuxi\u00e8me cas.
C'est pourquoi nous ne parlerons pas ici de son fonctionnement exact ni de toutes les fonctions que l'on peut utiliser (la doc officielle fait d\u00e9j\u00e0 \u00e7a mieux que nous), mais plut\u00f4t des pi\u00e8ges courants et des astuces pour les \u00e9viter.
"},{"location":"howto/querysets/#les-n1-queries","title":"LesN+1 queries
","text":""},{"location":"howto/querysets/#le-probleme","title":"Le probl\u00e8me","text":"Normalement, quand on veut r\u00e9cup\u00e9rer une liste, on fait une requ\u00eate et c'est fini. Mais des fois, \u00e7a n'est pas si simple. Par exemple, supposons que nous voulons r\u00e9cup\u00e9rer les 100 utilisateurs les plus riches, avec leurs informations client :
from core.models import User\n\nfor user in User.objects.order_by(\"-customer__amount\")[:100]:\n print(user.customer.amount)\n
Combien de requ\u00eates le bout de code suivant effectue-t-il ? 101. En deux pauvres lignes de code, nous avons demand\u00e9 \u00e0 la base de donn\u00e9es d'effectuer 101 requ\u00eates. Une requ\u00eate toute seule n'est d\u00e9j\u00e0 une op\u00e9ration anodine, alors je vous laisse imaginer ce que \u00e7a donne pour 101.
Si vous ne comprenez pourquoi ce nombre, c'est tr\u00e8s simple :
En effet, les informations client sont stock\u00e9es dans une autre table, mais le fait d'\u00e9tablir un lien de clef \u00e9trang\u00e8re permet de manipuler customer
comme si c'\u00e9tait un membre \u00e0 part enti\u00e8re de User
.
Il est \u00e0 noter cependant, que Django n'effectue une requ\u00eate que pour le premier acc\u00e8s \u00e0 un membre d'une relation de clef \u00e9trang\u00e8re. Toutes les fois suivantes, l'objet est d\u00e9j\u00e0 l\u00e0, et django le r\u00e9cup\u00e8re :
from core.models import User\n\n# l'utilisateur le plus riche\nuser = User.objects.order_by(\"-customer__amount\").first() # <-- requ\u00eate db\nprint(user.customer.amount) # <-- requ\u00eate db\nprint(user.customer.account_id) # on a d\u00e9j\u00e0 r\u00e9cup\u00e9r\u00e9 `customer`, donc pas de requ\u00eate\n
Ce n'est donc pas gravissime si vous faites cette erreur quand vous manipulez un seul objet. En revanche, quand vous en manipulez plusieurs, il faut r\u00e9gler le probl\u00e8me. Pour \u00e7a, il y a plusieurs m\u00e9thodes, en fonction de votre cas.
"},{"location":"howto/querysets/#select_related","title":"select_related
","text":"La m\u00e9thode la plus basique consiste \u00e0 annoter le queryset, avec la m\u00e9thode select_related()
. En faisant \u00e7a, Django fera une jointure sur l'autre table et demandera des informations en plus \u00e0 la db lors de la requ\u00eate.
De la sorte, lorsque vous appellerez le membre reli\u00e9, les informations seront d\u00e9j\u00e0 l\u00e0.
from core.models import User\n\nrichest = User.objects.order_by(\"-customer__amount\")\nfor user in richest.select_related(\"customer\")[:100]:\n print(user.customer)\n
Le code ci-dessus effectue une seule requ\u00eate. Chaque fois qu'on veut acc\u00e9der \u00e0 customer
, c'est bon, \u00e7a a d\u00e9j\u00e0 \u00e9t\u00e9 r\u00e9cup\u00e9r\u00e9 \u00e0 travers le select_related
.
prefetch_related
","text":"Maintenant, un cas plus compliqu\u00e9. Supposons que vous ne vouliez pas r\u00e9cup\u00e9rer des informations reli\u00e9es par une relation One-to-One, mais par une relation One-to-Many.
Par exemple, un utilisateur a un seul compte client, mais il peut avoir plusieurs cotisations \u00e0 son actif. Et dans ces cas-l\u00e0, annotate
ne marche plus. En effet, s'il peut exister plusieurs cotisations, comment savoir laquelle on veut ?
Il faut alors utiliser un prefetch_related
. C'est un m\u00e9canisme un peu diff\u00e9rent : au lieu de faire une jointure et d'ajouter les informations voulues dans la m\u00eame requ\u00eate, Django va effectuer une deuxi\u00e8me requ\u00eate pour r\u00e9cup\u00e9rer les \u00e9l\u00e9ments de l'autre table, puis, \u00e0 partir de ces \u00e9l\u00e9ments, peupler la relation de son c\u00f4t\u00e9.
C'est un m\u00e9canisme qui peut \u00eatre un peu co\u00fbteux en m\u00e9moire et qui demande une deuxi\u00e8me requ\u00eate, mais qui reste quand m\u00eame largement pr\u00e9f\u00e9rable \u00e0 faire N requ\u00eates en plus.
from core.models import User\n\nfor user in User.objects.prefetch_related(\"subscriptions\")[:100]:\n # c'est bon, la m\u00e9thode prefetch a r\u00e9cup\u00e9r\u00e9 en avance les `subscriptions`\n print(user.subscriptions.all())\n
Danger
La m\u00e9thode prefetch_related
ne marche que si vous utilisez la m\u00e9thode all()
pour acc\u00e9der au membre. Si vous utilisez une autre m\u00e9thode (comme filter
ou annotate
), alors Django effectuera une nouvelle requ\u00eate, et vous retomberez dans le probl\u00e8me initial.
from core.models import User\nfrom django.db.models import Count\n\nfor user in User.objects.prefetch_related(\"subscriptions\")[:100]:\n # Le prefetch_related ne marche plus !\n print(user.subscriptions.annotate(count=Count(\"*\")))\n
"},{"location":"howto/querysets/#recuperer-ce-dont-vous-avez-besoin","title":"R\u00e9cup\u00e9rer ce dont vous avez besoin","text":"Des fois (souvent, m\u00eame), penser explicitement \u00e0 la jointure est le meilleur choix.
En effet, vous remarquerez que dans tous les exemples pr\u00e9c\u00e9dents, nous n'utilisions qu'une partie des informations (par exemple, nous ne r\u00e9cup\u00e9rions que la somme d'argent sur les comptes, et \u00e9ventuellement le num\u00e9ro de compte).
Nous pouvons utiliser la m\u00e9thode annotate
pour sp\u00e9cifier explicitement les donn\u00e9es que l'on veut joindre \u00e0 notre requ\u00eate.
Quand nous voulions r\u00e9cup\u00e9rer les informations utilisateur, nous aurions tout aussi bien pu \u00e9crire :
from core.models import User\nfrom django.db.models import F\n\nrichest = User.objects.order_by(\"-customer__amount\")\nfor user in richest.annotate(amount=F(\"customer__amount\"))[:100]:\n print(user.amount)\n
On aurait m\u00eame pu r\u00e9organiser \u00e7a :
from core.models import User\nfrom django.db.models import F\n\nrichest = User.objects.annotate(amount=F(\"customer__amount\")).order_by(\"-amount\")\nfor user in richest[:100]:\n print(user.amount)\n
\u00c7a peut sembler moins bien qu'un select_related
, comme \u00e7a. Des fois, c'est en effet moins bien, et des fois c'est mieux. La comparaison est plus \u00e9vidente avec le prefetch_related
.
En effet, quand nous voulions r\u00e9cup\u00e9rer le nombre de cotisations des utilisateurs, le prefetch_related
ne marchait plus. Pourtant, nous voulions r\u00e9cup\u00e9rer une seule information.
Il aurait donc \u00e9t\u00e9 suffisant d'\u00e9crire :
from core.models import User\nfrom django.db.models import Count\n\nfor user in User.objects.annotate(nb_subscriptions=Count(\"subscriptions\"))[:100]:\n # Et l\u00e0 \u00e7a marche, en une seule requ\u00eate.\n print(user.nb_subscriptions)\n
Faire une jointure, c'est normal en SQL. Et pourtant avec Django on les oublie trop facilement. Posez-vous toujours la question des donn\u00e9es que vous pourriez avoir besoin d'annoter, et vous \u00e9viterez beaucoup d'ennuis.
"},{"location":"howto/querysets/#les-aggregations-manquees","title":"Les aggr\u00e9gations manqu\u00e9es","text":"Il arrive souvent que l'on veuille une information qui porte sur un ensemble d'objets de notre db.
Imaginons par exemple que nous voulons connaitre la somme totale des ventes faites \u00e0 un comptoir.
Nous avons tous suivi nos cours de programmation, nous \u00e9crivons donc instinctivement :
from counter.models import Counter\n\nfoyer = Counter.objects.get(name=\"Foyer\")\ntotal_amount = sum(\n sale.amount * sale.unit_price\n for sale in foyer.sellings.all()\n)\n
On pourrait penser qu'il n'y a pas de probl\u00e8me. Apr\u00e8s tout, on ne fait qu'une seule requ\u00eate. Eh bien si, il y a un probl\u00e8me : on fait beaucoup de choses en trop.
Concr\u00e8tement, on demande \u00e0 la base de donn\u00e9es de renvoyer toutes les informations, ce qui rallonge inutilement la dur\u00e9e de l'\u00e9change entre le serveur et la db, puis on perd du temps \u00e0 convertir ces informations en objets Python (op\u00e9ration qui a un co\u00fbt \u00e9galement), et enfin on reperd du temps \u00e0 calculer en Python quelque chose que la db aurait pu calculer \u00e0 notre plus bien plus vite.
Nous aurions d\u00fb aggr\u00e9ger la requ\u00eate, avec la m\u00e9thode aggregate
:
from counter.models import Counter\nfrom django.db.models import Sum, F\n\nfoyer = Counter.objects.get(name=\"Foyer\")\ntotal_amount = (\n foyer.sellings.aggregate(amount=Sum(F(\"amount\") * F(\"unit_price\"), default=0))\n)[\"amount__sum\"]\n
En effectuant cette requ\u00eate, la base de donn\u00e9es nous renverra exactement l'information dont nous avons besoin. Et de notre c\u00f4t\u00e9, nous n'aurons pas \u00e0 faire de traitement en plus.
"},{"location":"howto/querysets/#benchmark","title":"Benchmark","text":""},{"location":"howto/querysets/#ce-quil-faut-mesurer","title":"Ce qu'il faut mesurer","text":"Quand on parle d'interaction avec une base de donn\u00e9es, la question de la performance est cruciale. Et quand on parle de performance, on en vient forc\u00e9ment \u00e0 parler d'optimisation.
Or, pour optimiser, il faut savoir quoi optimiser. C'est-\u00e0-dire qu'il nous faut un benchmark pour \u00e9tudier les performances r\u00e9elles de notre code. En ce qui concerne des requ\u00eates \u00e0 une base de donn\u00e9es, deux aspects sont \u00e9tudiables :
Le premier aspect est celui qui nous int\u00e9resse le plus, puisqu'il est reli\u00e9 au probl\u00e8me le plus fr\u00e9quent et le plus facile \u00e0 mesurer. Le second aspect, au contraire, est bien moins fr\u00e9quent (dans 99% des cas, une requ\u00eate complexe prendra moins de temps que deux requ\u00eates, m\u00eame simples) et bien plus dur \u00e0 mesurer (il faut r\u00e9ussir \u00e0 faire des mesures fiables, dans un environnement proche de celui de la prod, avec les donn\u00e9es de la prod).
Nous consid\u00e9rerons donc que dans la quasi-totalit\u00e9 des cas, le probl\u00e8me vient du nombre de requ\u00eates, pas du temps d'ex\u00e9cution d'une requ\u00eate en particulier. Partez du principe que moins vous faites de requ\u00eates, mieux c'est, sans pr\u00eater attention au temps d'ex\u00e9cution des requ\u00eates.
Pour quantifier de mani\u00e8re fiables les requ\u00eates effectu\u00e9es, il y a quelques outils.
"},{"location":"howto/querysets/#django-debug-toolbar","title":"django-debug-toolbar
","text":"La django-debug-toolbar
est une interface disponible sur toutes les pages quand vous \u00eates en mode debug. Elle s'affiche \u00e0 droite et vous permet de voir toutes sortes d'informations, parmi lesquelles le nombre de requ\u00eates effectu\u00e9es.
Cette interface est tr\u00e8s pratique, puisqu'elle va plus loin que simplement compter les requ\u00eates, elle vous donne \u00e9galement le SQL qui a \u00e9t\u00e9 utilis\u00e9, l'endroit du code, avec fichier et num\u00e9ro de ligne, o\u00f9 cette requ\u00eate a \u00e9t\u00e9 faite et, encore mieux, elle vous indique quelles requ\u00eates semblent dupliqu\u00e9es.
Quand django-debug-toolbar
vous indique qu'une requ\u00eate a \u00e9t\u00e9 dupliqu\u00e9e quatre fois, cinq fois, ou m\u00eame deux cent fois (le chiffre peut sembler \u00e9norme, mais c'est d\u00e9j\u00e0 arriv\u00e9), vous pouvez \u00eatre s\u00fbr qu'il y a l\u00e0 quelque chose \u00e0 optimiser.
Warning
Le widget de django-debug-toolbar
ne s'affiche que sur les pages html. Si vous voulez \u00e9tudier autre chose, comme une simple fonction, ou bien comme une vue retournant du JSON, vous n'aurez donc pas django-debug-toolbar
.
connection.queries
","text":"Quand vous voulez examiner les requ\u00eates d'un bout de code en particulier, Django met \u00e0 disposition un m\u00e9canisme permettant d'examiner toutes les requ\u00eates qui sont faites : connection.queries
C'est un historique de toutes les requ\u00eates effectu\u00e9es, qui est assez simple \u00e0 utiliser :
from django.db import connection\nfrom core.models import User\n\nprint(len(connection.queries)) # 0\n\nnb_users = User.objects.count()\n\nprint(len(connection.queries)) # 1\nprint(connection.queries) # affiche toutes les requ\u00eates effectu\u00e9es\n
"},{"location":"howto/querysets/#assertnumqueries","title":"assertNumQueries
","text":"Quand on a mis en place une fonctionnalit\u00e9, ou qu'on en a am\u00e9lior\u00e9 les performances, on veut absolument \u00e9viter la r\u00e9gression.
Or, une r\u00e9gression ne se manifeste pas forc\u00e9ment dans l'apparition d'un bug : \u00e7a peut aussi \u00eatre une augmentation du temps d'ex\u00e9cution, possiblement caus\u00e9 par une augmentation du nombre de requ\u00eates.
C'est pour \u00e7a que django met \u00e0 disposition un moyen de tester automatiquement le nombre de requ\u00eates : assertNumQueries
.
Il s'agit d'un gestionnaire de contexte accessible dans les tests, qui teste le nombre de requ\u00eates effectu\u00e9es en son sein.
Par exemple :
from django.test import TestCase\nfrom django.shortcuts import reverse\n\n\nclass FooTest(TestCase):\n def test_nb_queries(self):\n \"\"\"Test that the number of db queries is stable.\"\"\"\n with self.assertNumQueries(6):\n self.client.get(reverse(\"foo:bar\"))\n
Si l'ex\u00e9cution de la route n\u00e9cessite plus ou moins de six requ\u00eates, alors le test \u00e9choue. S'il y a eu moins que le nombre de requ\u00eate attendu, alors tant mieux, modifiez le test pour coller au nouveau nombre (sous r\u00e9serve que tous les autres tests passent, bien s\u00fbr). Si par contre il y a eu plus, alors d\u00e9sol\u00e9, vous avez sans doute introduit une r\u00e9gression.
"},{"location":"howto/statics/","title":"G\u00e9rer les statics","text":""},{"location":"howto/statics/#cest-quoi-les-fichiers-statics","title":"C'est quoi les fichiers statics ?","text":"Les fichiers statics sont tous les fichiers qui ne sont pas g\u00e9n\u00e9r\u00e9s par le backend Django et qui sont t\u00e9l\u00e9charg\u00e9s par le navigateur. Cela comprend les fichiers css, javascript, images et autre.
La documentation officielle est tr\u00e8s compr\u00e9hensive.
Pour faire court, dans chaque module d'application il existe un dossier static
o\u00f9 mettre tous ces fichiers. Django se d\u00e9brouille ensuite pour aller chercher ce qu'il faut \u00e0 l'int\u00e9rieur.
Pour acc\u00e9der \u00e0 un fichier static dans un template Jinja il suffit d'utiliser la fonction static
.
{# Exemple pour ajouter sith/core/static/core/base.css #}\n <link rel=\"stylesheet\" href=\"{{ static('core/base.css') }}\">\n
"},{"location":"howto/statics/#lintegration-des-scss","title":"L'int\u00e9gration des scss","text":"Les scss sont \u00e0 mettre dans le dossier static comme le reste. Il n'y a aucune diff\u00e9rence avec le reste pour les inclure, le syst\u00e8me se d\u00e9brouille automatiquement pour les transformer en .css
{# Exemple pour ajouter sith/core/static/core/base.scss #}\n <link rel=\"stylesheet\" href=\"{{ static('core/style.scss') }}\">\n
"},{"location":"howto/statics/#lintegration-webpack","title":"L'int\u00e9gration webpack","text":"Webpack est int\u00e9gr\u00e9 un peu diff\u00e9rement. Le principe est tr\u00e8s similaire mais les fichiers sont \u00e0 mettre dans un dossier static/webpack
de l'application \u00e0 la place.
Pour acc\u00e9der au fichier, il faut utiliser static
comme pour le reste mais en ajouter webpack/
comme prefix.
{# Example pour ajouter sith/core/webpack/alpine-index.js #}\n <script src=\"{{ static('webpack/alpine-index.js') }}\" defer></script>\n <script src=\"{{ static('webpack/other-index.ts') }}\" defer></script>\n
Note
Seuls les fichiers se terminant par index.js
sont export\u00e9s par webpack. Les autres fichiers sont disponibles \u00e0 l'import dans le JavaScript comme si ils \u00e9taient tous au m\u00eame niveau.
Pour importer au sein de webpack, il faut pr\u00e9fixer ses imports de #app:
.
Exemple:
import { paginated } from \"#core:utils/api\";\n
"},{"location":"howto/statics/#comment-ca-fonctionne-le-post-processing","title":"Comment \u00e7a fonctionne le post processing ?","text":"Le post processing est g\u00e9r\u00e9 par le module staticfiles
. Les fichiers sont compil\u00e9s \u00e0 la vol\u00e9e en mode d\u00e9veloppement.
Pour la production, ils sont compil\u00e9s uniquement lors du ./manage.py collectstatic
. Les fichiers g\u00e9n\u00e9r\u00e9s sont ajout\u00e9s dans le dossier staticfiles/generated
. Celui-ci est ensuite enregistr\u00e9 comme dossier suppl\u00e9mentaire \u00e0 collecter dans Django.
Il peut arriver que le type de cotisation propos\u00e9 varie en prix et en dur\u00e9e. Ces param\u00e8tres sont configurables directement dans les param\u00e8tres du projet.
Pour modifier les cotisations disponibles, tout se g\u00e8re dans la configuration avec la variable SITH_SUBSCRIPTIONS
.
Par exemple, si nous voulons ajouter une nouvelle cotisation d'un mois, voici ce que nous ajouterons :
settings.pyfrom django.utils.translation import gettext_lazy as _\n\nSITH_SUBSCRIPTIONS = {\n # Voici un \u00e9chantillon de la v\u00e9ritable configuration \u00e0 l'heure de l'\u00e9criture.\n # Celle-ci est donn\u00e9e \u00e0 titre d'exemple pour mieux comprendre comment cela fonctionne.\n \"un-semestre\": {\"name\": _(\"One semester\"), \"price\": 15, \"duration\": 1},\n \"deux-semestres\": {\"name\": _(\"Two semesters\"), \"price\": 28, \"duration\": 2},\n \"cursus-tronc-commun\": {\n \"name\": _(\"Common core cursus\"),\n \"price\": 45,\n \"duration\": 4,\n },\n \"cursus-branche\": {\"name\": _(\"Branch cursus\"), \"price\": 45, \"duration\": 6},\n \"cursus-alternant\": {\"name\": _(\"Alternating cursus\"), \"price\": 30, \"duration\": 6},\n \"membre-honoraire\": {\"name\": _(\"Honorary member\"), \"price\": 0, \"duration\": 666},\n \"un-jour\": {\"name\": _(\"One day\"), \"price\": 0, \"duration\": 0.00555333},\n\n # On rajoute ici notre cotisation\n # Elle se nomme \"Un mois\"\n # Co\u00fbte 6\u20ac\n # Dure 1 mois (on raisonne en semestre, ici, c'est 1/6 de semestre)\n \"un-mois\": {\"name\": _(\"One month\"), \"price\": 6, \"duration\": 0.166}\n}\n
Une fois ceci fait, il faut cr\u00e9er une nouvelle migration :
python ./manage.py makemigrations subscription\npython ./manage.py migrate\n
N'oubliez pas non plus les traductions (cf. ici)
"},{"location":"howto/terminal/","title":"Terminal","text":""},{"location":"howto/terminal/#quel-terminal-utiliser","title":"Quel terminal utiliser ?","text":"Quel que soit votre configuration, si vous avez r\u00e9ussi \u00e0 installer le projet, il y a de fortes chances que bash existe sur votre ordinateur. Certains d'entre vous utilisent peut-\u00eatre un autre shell, comme zsh
.
En effet, bash
est bien, il fait le taff ; mais son ergonomie finit par montrer ses limites. C'est pourquoi il existe des shells plus avanc\u00e9s, qui peuvent am\u00e9liorer l'ergonomie, la compl\u00e9tion des commandes, et l'apparence. C'est le cas de zsh
. Certains vont m\u00eame plus loin et refont carr\u00e9ment la syntaxe. C'est le cas de nu
.
Pour choisir un terminal, demandez-vous juste quel est votre usage du terminal :
bash
convient parfaitement.zsh
est parfait pour vous.nu
vous plaira \u00e0 coup s\u00fbr.Note
Ce ne sont que des suggestions. Le meilleur choix restera toujours celui avec lequel vous \u00eates le plus confortable.
"},{"location":"howto/terminal/#commandes-utiles","title":"Commandes utiles","text":""},{"location":"howto/terminal/#compter-le-nombre-de-lignes-du-projet","title":"Compter le nombre de lignes du projet","text":"bash/zshnusudo apt install cloc\ncloc --exclude-dir=doc,env .\n
Ok, c'est de la triche, on installe un package externe. Mais bon, \u00e7a marche, et l'\u00e9quivalent pur bash serait carr\u00e9ment plus moche. Nombre de lignes, group\u00e9 par fichier :
ls **/*.py | insert linecount { get name | open | lines | length }\n
Nombre de lignes total :
ls **/*.py | insert linecount { get name | open | lines | length } | math sum\n
Vous pouvez aussi exlure les lignes vides et les les lignes de commentaire :
ls **/*.py |\ninsert linecount {\n get name |\n open |\n lines |\n each { str trim } |\n filter { |l| not ($l | str starts-with \"#\") } | # commentaires\n filter { |l| ($l | str length) > 0 } | # lignes vides\n length\n} |\nmath sum\n
"},{"location":"howto/translation/","title":"G\u00e9rer les traductions","text":"Le code du site est enti\u00e8rement \u00e9crit en anglais, le texte affich\u00e9 aux utilisateurs l'est \u00e9galement. La traduction en fran\u00e7ais se fait ult\u00e9rieurement avec un fichier de traduction. Voici un petit guide rapide pour apprendre \u00e0 s'en servir.
"},{"location":"howto/translation/#dans-le-code-du-logiciel","title":"Dans le code du logiciel","text":"Imaginons que nous souhaitons afficher \"Hello\" et le traduire en fran\u00e7ais. Voici comment signaler que ce mot doit \u00eatre traduit.
Si le mot est dans le code Python :
from django.utils.translation import gettext as _\n\nhelp_text=_(\"Hello\")\n
Si le mot appara\u00eet dans le template Jinja :
{% trans %}Hello{% endtrans %}\n
Si on est dans un fichier javascript ou typescript :
gettext(\"Hello\");\n
"},{"location":"howto/translation/#generer-le-fichier-djangopo","title":"G\u00e9n\u00e9rer le fichier django.po","text":"La traduction se fait en trois \u00e9tapes. Il faut d'abord g\u00e9n\u00e9rer un fichier de traductions, l'\u00e9diter et enfin le compiler au format binaire pour qu'il soit lu par le serveur.
./manage.py makemessages --locale=fr -e py,jinja --ignore=node_modules # Pour le backend\n./manage.py makemessages --locale=fr -d djangojs -e js,ts --ignore=node_modules # Pour le frontend\n
"},{"location":"howto/translation/#editer-le-fichier-djangopo","title":"\u00c9diter le fichier django.po","text":"# locale/fr/LC_MESSAGES/django.po\n\n# ...\nmsgid \"Hello\"\nmsgstr \"\" # Ligne \u00e0 modifier\n\n# ...\n
Note
Si les commentaires suivants apparaissent, pensez \u00e0 les supprimer. Ils peuvent g\u00eaner votre traduction.
#, fuzzy\n#| msgid \"Bonjour\"\n
"},{"location":"howto/translation/#generer-le-fichier-djangomo","title":"G\u00e9n\u00e9rer le fichier django.mo","text":"Il s'agit de la derni\u00e8re \u00e9tape. Un fichier binaire est g\u00e9n\u00e9r\u00e9 \u00e0 partir du fichier django.mo.
./manage.py compilemessages\n
Tip
Pensez \u00e0 red\u00e9marrer le serveur si les traductions ne s'affichent pas
"},{"location":"howto/weekmail/","title":"Modifier le weekmail","text":"Le site est capable de g\u00e9n\u00e9rer des mails automatiques contenant l\u2019agr\u00e9gation d'articles \u00e9crits par les administrateurs de clubs. Le contenu est ins\u00e9r\u00e9 dans un template standardis\u00e9 et contr\u00f4l\u00e9 directement dans le code. Il arrive r\u00e9guli\u00e8rement que l'\u00e9quipe communication souhaite modifier ce template. Que ce soient les couleurs, l'agencement ou encore la banni\u00e8re ou le footer, voici tout ce qu'il y a \u00e0 savoir sur le fonctionnement du weekmail en commen\u00e7ant par la classe qui le contr\u00f4le.
"},{"location":"howto/weekmail/#modifier-la-banniere-et-le-footer","title":"Modifier la banni\u00e8re et le footer","text":"Ces \u00e9l\u00e9ments sont contr\u00f4l\u00e9s par les m\u00e9thodes get_banner
et get_footer
de la classe Weekmail
. Les modifier est donc tr\u00e8s simple, il suffit de modifier le contenu de la fonction et de rajouter les nouvelles images dans les statics.
Les images sont \u00e0 ajouter dans core/static/com/img
et sont \u00e0 nommer selon le type (banner ou footer), le semestre (Automne ou Printemps) et l'ann\u00e9e. Exemple : weekmail_bannerA18.jpg
pour la banni\u00e8re de l'automne 2018.
from django.conf import settings\nfrom django.templatetags.static import static\n\n# S\u00e9lectionnez le fichier de banni\u00e8re pour le weekmail de l'automne 2018\n\ndef get_banner(self):\n return \"http://\" + settings.SITH_URL + static(\"com/img/weekmail_bannerA18.jpg\")\n
Note
Penser \u00e0 prendre les images au format jpg et \u00e0 les compresser un peu, pour qu'elles soient le plus l\u00e9ger possible, c'est bien mieux pour l'utilisateur final.
Warning
Pensez \u00e0 laisser les anciennes images dans le dossier pour que les anciens weekmails ne soient pas affect\u00e9s par les changements.
"},{"location":"howto/weekmail/#modifier-le-template","title":"Modifier le template","text":"Il existe deux templates diff\u00e9rents :
Ces deux templates sont respectivement accessibles aux emplacements suivants :
com/templates/com/weekmail_renderer_html.jinja
com/templates/com/weekmail_renderer_text.jinja
Note
Pour le rendu HTML, pensez \u00e0 utiliser le CSS et le javascript le plus simple possible pour que le rendu se fasse correctement dans les clients mails qui sont souvent capricieux.
Note
Le CSS est inclus statiquement pour que toute modification ult\u00e9rieure de celui-ci n'affecte pas les versions pr\u00e9c\u00e9demment envoy\u00e9es.
Warning
Si vous souhaitez ajouter du contenu, n'oubliez pas de bien inclure ce contenu dans les deux templates.
"},{"location":"reference/accounting/models/","title":"Models","text":""},{"location":"reference/accounting/models/#accounting.models.CurrencyField","title":"CurrencyField(*args, **kwargs)
","text":" Bases: DecimalField
Custom database field used for currency.
Source code inaccounting/models.py
def __init__(self, *args, **kwargs):\n kwargs[\"max_digits\"] = 12\n kwargs[\"decimal_places\"] = 2\n super().__init__(*args, **kwargs)\n
"},{"location":"reference/accounting/models/#accounting.models.Company","title":"Company
","text":" Bases: Model
is_owned_by(user)
","text":"Check if that object can be edited by the given user.
Source code inaccounting/models.py
def is_owned_by(self, user):\n \"\"\"Check if that object can be edited by the given user.\"\"\"\n return user.is_in_group(pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID)\n
"},{"location":"reference/accounting/models/#accounting.models.Company.can_be_edited_by","title":"can_be_edited_by(user)
","text":"Check if that object can be edited by the given user.
Source code inaccounting/models.py
def can_be_edited_by(self, user):\n \"\"\"Check if that object can be edited by the given user.\"\"\"\n return user.memberships.filter(\n end_date=None, club__role=settings.SITH_CLUB_ROLES_ID[\"Treasurer\"]\n ).exists()\n
"},{"location":"reference/accounting/models/#accounting.models.Company.can_be_viewed_by","title":"can_be_viewed_by(user)
","text":"Check if that object can be viewed by the given user.
Source code inaccounting/models.py
def can_be_viewed_by(self, user):\n \"\"\"Check if that object can be viewed by the given user.\"\"\"\n return user.memberships.filter(\n end_date=None, club__role_gte=settings.SITH_CLUB_ROLES_ID[\"Treasurer\"]\n ).exists()\n
"},{"location":"reference/accounting/models/#accounting.models.BankAccount","title":"BankAccount
","text":" Bases: Model
is_owned_by(user)
","text":"Check if that object can be edited by the given user.
Source code inaccounting/models.py
def is_owned_by(self, user):\n \"\"\"Check if that object can be edited by the given user.\"\"\"\n if user.is_anonymous:\n return False\n if user.is_in_group(pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID):\n return True\n m = self.club.get_membership_for(user)\n return m is not None and m.role >= settings.SITH_CLUB_ROLES_ID[\"Treasurer\"]\n
"},{"location":"reference/accounting/models/#accounting.models.ClubAccount","title":"ClubAccount
","text":" Bases: Model
is_owned_by(user)
","text":"Check if that object can be edited by the given user.
Source code inaccounting/models.py
def is_owned_by(self, user):\n \"\"\"Check if that object can be edited by the given user.\"\"\"\n if user.is_anonymous:\n return False\n return user.is_in_group(pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID)\n
"},{"location":"reference/accounting/models/#accounting.models.ClubAccount.can_be_edited_by","title":"can_be_edited_by(user)
","text":"Check if that object can be edited by the given user.
Source code inaccounting/models.py
def can_be_edited_by(self, user):\n \"\"\"Check if that object can be edited by the given user.\"\"\"\n m = self.club.get_membership_for(user)\n return m and m.role == settings.SITH_CLUB_ROLES_ID[\"Treasurer\"]\n
"},{"location":"reference/accounting/models/#accounting.models.ClubAccount.can_be_viewed_by","title":"can_be_viewed_by(user)
","text":"Check if that object can be viewed by the given user.
Source code inaccounting/models.py
def can_be_viewed_by(self, user):\n \"\"\"Check if that object can be viewed by the given user.\"\"\"\n m = self.club.get_membership_for(user)\n return m and m.role >= settings.SITH_CLUB_ROLES_ID[\"Treasurer\"]\n
"},{"location":"reference/accounting/models/#accounting.models.GeneralJournal","title":"GeneralJournal
","text":" Bases: Model
Class storing all the operations for a period of time.
"},{"location":"reference/accounting/models/#accounting.models.GeneralJournal.is_owned_by","title":"is_owned_by(user)
","text":"Check if that object can be edited by the given user.
Source code inaccounting/models.py
def is_owned_by(self, user):\n \"\"\"Check if that object can be edited by the given user.\"\"\"\n if user.is_anonymous:\n return False\n if user.is_in_group(pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID):\n return True\n return self.club_account.can_be_edited_by(user)\n
"},{"location":"reference/accounting/models/#accounting.models.GeneralJournal.can_be_edited_by","title":"can_be_edited_by(user)
","text":"Check if that object can be edited by the given user.
Source code inaccounting/models.py
def can_be_edited_by(self, user):\n \"\"\"Check if that object can be edited by the given user.\"\"\"\n if user.is_in_group(pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID):\n return True\n return self.club_account.can_be_edited_by(user)\n
"},{"location":"reference/accounting/models/#accounting.models.Operation","title":"Operation
","text":" Bases: Model
An operation is a line in the journal, a debit or a credit.
"},{"location":"reference/accounting/models/#accounting.models.Operation.is_owned_by","title":"is_owned_by(user)
","text":"Check if that object can be edited by the given user.
Source code inaccounting/models.py
def is_owned_by(self, user):\n \"\"\"Check if that object can be edited by the given user.\"\"\"\n if user.is_anonymous:\n return False\n if user.is_in_group(pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID):\n return True\n if self.journal.closed:\n return False\n m = self.journal.club_account.club.get_membership_for(user)\n return m is not None and m.role >= settings.SITH_CLUB_ROLES_ID[\"Treasurer\"]\n
"},{"location":"reference/accounting/models/#accounting.models.Operation.can_be_edited_by","title":"can_be_edited_by(user)
","text":"Check if that object can be edited by the given user.
Source code inaccounting/models.py
def can_be_edited_by(self, user):\n \"\"\"Check if that object can be edited by the given user.\"\"\"\n if user.is_in_group(pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID):\n return True\n if self.journal.closed:\n return False\n m = self.journal.club_account.club.get_membership_for(user)\n return m is not None and m.role == settings.SITH_CLUB_ROLES_ID[\"Treasurer\"]\n
"},{"location":"reference/accounting/models/#accounting.models.AccountingType","title":"AccountingType
","text":" Bases: Model
Accounting types.
Those are numbers used in accounting to classify operations
"},{"location":"reference/accounting/models/#accounting.models.AccountingType.is_owned_by","title":"is_owned_by(user)
","text":"Check if that object can be edited by the given user.
Source code inaccounting/models.py
def is_owned_by(self, user):\n \"\"\"Check if that object can be edited by the given user.\"\"\"\n if user.is_anonymous:\n return False\n return user.is_in_group(pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID)\n
"},{"location":"reference/accounting/models/#accounting.models.SimplifiedAccountingType","title":"SimplifiedAccountingType
","text":" Bases: Model
Simplified version of AccountingType
.
Label
","text":" Bases: Model
Label allow a club to sort its operations.
"},{"location":"reference/accounting/views/","title":"Views","text":""},{"location":"reference/accounting/views/#accounting.views.AccountingType","title":"AccountingType
","text":" Bases: Model
Accounting types.
Those are numbers used in accounting to classify operations
"},{"location":"reference/accounting/views/#accounting.views.AccountingType.is_owned_by","title":"is_owned_by(user)
","text":"Check if that object can be edited by the given user.
Source code inaccounting/models.py
def is_owned_by(self, user):\n \"\"\"Check if that object can be edited by the given user.\"\"\"\n if user.is_anonymous:\n return False\n return user.is_in_group(pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID)\n
"},{"location":"reference/accounting/views/#accounting.views.BankAccount","title":"BankAccount
","text":" Bases: Model
is_owned_by(user)
","text":"Check if that object can be edited by the given user.
Source code inaccounting/models.py
def is_owned_by(self, user):\n \"\"\"Check if that object can be edited by the given user.\"\"\"\n if user.is_anonymous:\n return False\n if user.is_in_group(pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID):\n return True\n m = self.club.get_membership_for(user)\n return m is not None and m.role >= settings.SITH_CLUB_ROLES_ID[\"Treasurer\"]\n
"},{"location":"reference/accounting/views/#accounting.views.ClubAccount","title":"ClubAccount
","text":" Bases: Model
is_owned_by(user)
","text":"Check if that object can be edited by the given user.
Source code inaccounting/models.py
def is_owned_by(self, user):\n \"\"\"Check if that object can be edited by the given user.\"\"\"\n if user.is_anonymous:\n return False\n return user.is_in_group(pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID)\n
"},{"location":"reference/accounting/views/#accounting.views.ClubAccount.can_be_edited_by","title":"can_be_edited_by(user)
","text":"Check if that object can be edited by the given user.
Source code inaccounting/models.py
def can_be_edited_by(self, user):\n \"\"\"Check if that object can be edited by the given user.\"\"\"\n m = self.club.get_membership_for(user)\n return m and m.role == settings.SITH_CLUB_ROLES_ID[\"Treasurer\"]\n
"},{"location":"reference/accounting/views/#accounting.views.ClubAccount.can_be_viewed_by","title":"can_be_viewed_by(user)
","text":"Check if that object can be viewed by the given user.
Source code inaccounting/models.py
def can_be_viewed_by(self, user):\n \"\"\"Check if that object can be viewed by the given user.\"\"\"\n m = self.club.get_membership_for(user)\n return m and m.role >= settings.SITH_CLUB_ROLES_ID[\"Treasurer\"]\n
"},{"location":"reference/accounting/views/#accounting.views.Company","title":"Company
","text":" Bases: Model
is_owned_by(user)
","text":"Check if that object can be edited by the given user.
Source code inaccounting/models.py
def is_owned_by(self, user):\n \"\"\"Check if that object can be edited by the given user.\"\"\"\n return user.is_in_group(pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID)\n
"},{"location":"reference/accounting/views/#accounting.views.Company.can_be_edited_by","title":"can_be_edited_by(user)
","text":"Check if that object can be edited by the given user.
Source code inaccounting/models.py
def can_be_edited_by(self, user):\n \"\"\"Check if that object can be edited by the given user.\"\"\"\n return user.memberships.filter(\n end_date=None, club__role=settings.SITH_CLUB_ROLES_ID[\"Treasurer\"]\n ).exists()\n
"},{"location":"reference/accounting/views/#accounting.views.Company.can_be_viewed_by","title":"can_be_viewed_by(user)
","text":"Check if that object can be viewed by the given user.
Source code inaccounting/models.py
def can_be_viewed_by(self, user):\n \"\"\"Check if that object can be viewed by the given user.\"\"\"\n return user.memberships.filter(\n end_date=None, club__role_gte=settings.SITH_CLUB_ROLES_ID[\"Treasurer\"]\n ).exists()\n
"},{"location":"reference/accounting/views/#accounting.views.GeneralJournal","title":"GeneralJournal
","text":" Bases: Model
Class storing all the operations for a period of time.
"},{"location":"reference/accounting/views/#accounting.views.GeneralJournal.is_owned_by","title":"is_owned_by(user)
","text":"Check if that object can be edited by the given user.
Source code inaccounting/models.py
def is_owned_by(self, user):\n \"\"\"Check if that object can be edited by the given user.\"\"\"\n if user.is_anonymous:\n return False\n if user.is_in_group(pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID):\n return True\n return self.club_account.can_be_edited_by(user)\n
"},{"location":"reference/accounting/views/#accounting.views.GeneralJournal.can_be_edited_by","title":"can_be_edited_by(user)
","text":"Check if that object can be edited by the given user.
Source code inaccounting/models.py
def can_be_edited_by(self, user):\n \"\"\"Check if that object can be edited by the given user.\"\"\"\n if user.is_in_group(pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID):\n return True\n return self.club_account.can_be_edited_by(user)\n
"},{"location":"reference/accounting/views/#accounting.views.Label","title":"Label
","text":" Bases: Model
Label allow a club to sort its operations.
"},{"location":"reference/accounting/views/#accounting.views.Operation","title":"Operation
","text":" Bases: Model
An operation is a line in the journal, a debit or a credit.
"},{"location":"reference/accounting/views/#accounting.views.Operation.is_owned_by","title":"is_owned_by(user)
","text":"Check if that object can be edited by the given user.
Source code inaccounting/models.py
def is_owned_by(self, user):\n \"\"\"Check if that object can be edited by the given user.\"\"\"\n if user.is_anonymous:\n return False\n if user.is_in_group(pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID):\n return True\n if self.journal.closed:\n return False\n m = self.journal.club_account.club.get_membership_for(user)\n return m is not None and m.role >= settings.SITH_CLUB_ROLES_ID[\"Treasurer\"]\n
"},{"location":"reference/accounting/views/#accounting.views.Operation.can_be_edited_by","title":"can_be_edited_by(user)
","text":"Check if that object can be edited by the given user.
Source code inaccounting/models.py
def can_be_edited_by(self, user):\n \"\"\"Check if that object can be edited by the given user.\"\"\"\n if user.is_in_group(pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID):\n return True\n if self.journal.closed:\n return False\n m = self.journal.club_account.club.get_membership_for(user)\n return m is not None and m.role == settings.SITH_CLUB_ROLES_ID[\"Treasurer\"]\n
"},{"location":"reference/accounting/views/#accounting.views.SimplifiedAccountingType","title":"SimplifiedAccountingType
","text":" Bases: Model
Simplified version of AccountingType
.
BankAccountListView
","text":" Bases: CanViewMixin
, ListView
A list view for the admins.
"},{"location":"reference/accounting/views/#accounting.views.SimplifiedAccountingTypeListView","title":"SimplifiedAccountingTypeListView
","text":" Bases: CanViewMixin
, ListView
A list view for the admins.
"},{"location":"reference/accounting/views/#accounting.views.SimplifiedAccountingTypeEditView","title":"SimplifiedAccountingTypeEditView
","text":" Bases: CanViewMixin
, UpdateView
An edit view for the admins.
"},{"location":"reference/accounting/views/#accounting.views.SimplifiedAccountingTypeCreateView","title":"SimplifiedAccountingTypeCreateView
","text":" Bases: CanCreateMixin
, CreateView
Create an accounting type (for the admins).
"},{"location":"reference/accounting/views/#accounting.views.AccountingTypeListView","title":"AccountingTypeListView
","text":" Bases: CanViewMixin
, ListView
A list view for the admins.
"},{"location":"reference/accounting/views/#accounting.views.AccountingTypeEditView","title":"AccountingTypeEditView
","text":" Bases: CanViewMixin
, UpdateView
An edit view for the admins.
"},{"location":"reference/accounting/views/#accounting.views.AccountingTypeCreateView","title":"AccountingTypeCreateView
","text":" Bases: CanCreateMixin
, CreateView
Create an accounting type (for the admins).
"},{"location":"reference/accounting/views/#accounting.views.BankAccountEditView","title":"BankAccountEditView
","text":" Bases: CanViewMixin
, UpdateView
An edit view for the admins.
"},{"location":"reference/accounting/views/#accounting.views.BankAccountDetailView","title":"BankAccountDetailView
","text":" Bases: CanViewMixin
, DetailView
A detail view, listing every club account.
"},{"location":"reference/accounting/views/#accounting.views.BankAccountCreateView","title":"BankAccountCreateView
","text":" Bases: CanCreateMixin
, CreateView
Create a bank account (for the admins).
"},{"location":"reference/accounting/views/#accounting.views.BankAccountDeleteView","title":"BankAccountDeleteView
","text":" Bases: CanEditPropMixin
, DeleteView
Delete a bank account (for the admins).
"},{"location":"reference/accounting/views/#accounting.views.ClubAccountEditView","title":"ClubAccountEditView
","text":" Bases: CanViewMixin
, UpdateView
An edit view for the admins.
"},{"location":"reference/accounting/views/#accounting.views.ClubAccountDetailView","title":"ClubAccountDetailView
","text":" Bases: CanViewMixin
, DetailView
A detail view, listing every journal.
"},{"location":"reference/accounting/views/#accounting.views.ClubAccountCreateView","title":"ClubAccountCreateView
","text":" Bases: CanCreateMixin
, CreateView
Create a club account (for the admins).
"},{"location":"reference/accounting/views/#accounting.views.ClubAccountDeleteView","title":"ClubAccountDeleteView
","text":" Bases: CanEditPropMixin
, DeleteView
Delete a club account (for the admins).
"},{"location":"reference/accounting/views/#accounting.views.JournalTabsMixin","title":"JournalTabsMixin
","text":" Bases: TabedViewMixin
JournalCreateView
","text":" Bases: CanCreateMixin
, CreateView
Create a general journal.
"},{"location":"reference/accounting/views/#accounting.views.JournalDetailView","title":"JournalDetailView
","text":" Bases: JournalTabsMixin
, CanViewMixin
, DetailView
A detail view, listing every operation.
"},{"location":"reference/accounting/views/#accounting.views.JournalEditView","title":"JournalEditView
","text":" Bases: CanEditMixin
, UpdateView
Update a general journal.
"},{"location":"reference/accounting/views/#accounting.views.JournalDeleteView","title":"JournalDeleteView
","text":" Bases: CanEditPropMixin
, DeleteView
Delete a club account (for the admins).
"},{"location":"reference/accounting/views/#accounting.views.OperationForm","title":"OperationForm(*args, **kwargs)
","text":" Bases: ModelForm
accounting/views.py
def __init__(self, *args, **kwargs):\n club_account = kwargs.pop(\"club_account\", None)\n super().__init__(*args, **kwargs)\n if club_account:\n self.fields[\"label\"].queryset = club_account.labels.order_by(\"name\").all()\n if self.instance.target_type == \"USER\":\n self.fields[\"user\"].initial = self.instance.target_id\n elif self.instance.target_type == \"ACCOUNT\":\n self.fields[\"club_account\"].initial = self.instance.target_id\n elif self.instance.target_type == \"CLUB\":\n self.fields[\"club\"].initial = self.instance.target_id\n elif self.instance.target_type == \"COMPANY\":\n self.fields[\"company\"].initial = self.instance.target_id\n
"},{"location":"reference/accounting/views/#accounting.views.OperationCreateView","title":"OperationCreateView
","text":" Bases: CanCreateMixin
, CreateView
Create an operation.
"},{"location":"reference/accounting/views/#accounting.views.OperationCreateView.get_context_data","title":"get_context_data(**kwargs)
","text":"Add journal to the context.
Source code inaccounting/views.py
def get_context_data(self, **kwargs):\n \"\"\"Add journal to the context.\"\"\"\n kwargs = super().get_context_data(**kwargs)\n if self.journal:\n kwargs[\"object\"] = self.journal\n return kwargs\n
"},{"location":"reference/accounting/views/#accounting.views.OperationEditView","title":"OperationEditView
","text":" Bases: CanEditMixin
, UpdateView
An edit view, working as detail for the moment.
"},{"location":"reference/accounting/views/#accounting.views.OperationEditView.get_context_data","title":"get_context_data(**kwargs)
","text":"Add journal to the context.
Source code inaccounting/views.py
def get_context_data(self, **kwargs):\n \"\"\"Add journal to the context.\"\"\"\n kwargs = super().get_context_data(**kwargs)\n kwargs[\"object\"] = self.object.journal\n return kwargs\n
"},{"location":"reference/accounting/views/#accounting.views.OperationPDFView","title":"OperationPDFView
","text":" Bases: CanViewMixin
, DetailView
Display the PDF of a given operation.
"},{"location":"reference/accounting/views/#accounting.views.JournalNatureStatementView","title":"JournalNatureStatementView
","text":" Bases: JournalTabsMixin
, CanViewMixin
, DetailView
Display a statement sorted by labels.
"},{"location":"reference/accounting/views/#accounting.views.JournalNatureStatementView.get_context_data","title":"get_context_data(**kwargs)
","text":"Add infos to the context.
Source code inaccounting/views.py
def get_context_data(self, **kwargs):\n \"\"\"Add infos to the context.\"\"\"\n kwargs = super().get_context_data(**kwargs)\n kwargs[\"statement\"] = self.big_statement()\n return kwargs\n
"},{"location":"reference/accounting/views/#accounting.views.JournalPersonStatementView","title":"JournalPersonStatementView
","text":" Bases: JournalTabsMixin
, CanViewMixin
, DetailView
Calculate a dictionary with operation target and sum of operations.
"},{"location":"reference/accounting/views/#accounting.views.JournalPersonStatementView.get_context_data","title":"get_context_data(**kwargs)
","text":"Add journal to the context.
Source code inaccounting/views.py
def get_context_data(self, **kwargs):\n \"\"\"Add journal to the context.\"\"\"\n kwargs = super().get_context_data(**kwargs)\n kwargs[\"credit_statement\"] = self.statement(\"CREDIT\")\n kwargs[\"debit_statement\"] = self.statement(\"DEBIT\")\n kwargs[\"total_credit\"] = self.total(\"CREDIT\")\n kwargs[\"total_debit\"] = self.total(\"DEBIT\")\n return kwargs\n
"},{"location":"reference/accounting/views/#accounting.views.JournalAccountingStatementView","title":"JournalAccountingStatementView
","text":" Bases: JournalTabsMixin
, CanViewMixin
, DetailView
Calculate a dictionary with operation type and sum of operations.
"},{"location":"reference/accounting/views/#accounting.views.JournalAccountingStatementView.get_context_data","title":"get_context_data(**kwargs)
","text":"Add journal to the context.
Source code inaccounting/views.py
def get_context_data(self, **kwargs):\n \"\"\"Add journal to the context.\"\"\"\n kwargs = super().get_context_data(**kwargs)\n kwargs[\"statement\"] = self.statement()\n return kwargs\n
"},{"location":"reference/accounting/views/#accounting.views.CompanyListView","title":"CompanyListView
","text":" Bases: CanViewMixin
, ListView
CompanyCreateView
","text":" Bases: CanCreateMixin
, CreateView
Create a company.
"},{"location":"reference/accounting/views/#accounting.views.CompanyEditView","title":"CompanyEditView
","text":" Bases: CanCreateMixin
, UpdateView
Edit a company.
"},{"location":"reference/accounting/views/#accounting.views.LabelListView","title":"LabelListView
","text":" Bases: CanViewMixin
, DetailView
LabelCreateView
","text":" Bases: CanCreateMixin
, CreateView
LabelEditView
","text":" Bases: CanEditMixin
, UpdateView
LabelDeleteView
","text":" Bases: CanEditMixin
, DeleteView
CloseCustomerAccountForm
","text":" Bases: Form
RefoundAccountView
","text":" Bases: FormView
Create a selling with the same amount than the current user money.
"},{"location":"reference/antispam/forms/","title":"Forms","text":""},{"location":"reference/antispam/forms/#antispam.forms.ToxicDomain","title":"ToxicDomain
","text":" Bases: Model
Domain marked as spam in public databases
"},{"location":"reference/antispam/forms/#antispam.forms.AntiSpamEmailField","title":"AntiSpamEmailField
","text":" Bases: EmailField
An email field that email addresses with a known toxic domain.
"},{"location":"reference/antispam/models/","title":"Models","text":""},{"location":"reference/antispam/models/#antispam.models.ToxicDomain","title":"ToxicDomain
","text":" Bases: Model
Domain marked as spam in public databases
"},{"location":"reference/club/models/","title":"Models","text":""},{"location":"reference/club/models/#club.models.Club","title":"Club
","text":" Bases: Model
The Club class, made as a tree to allow nice tidy organization.
"},{"location":"reference/club/models/#club.models.Club.check_loop","title":"check_loop()
","text":"Raise a validation error when a loop is found within the parent list.
Source code inclub/models.py
def check_loop(self):\n \"\"\"Raise a validation error when a loop is found within the parent list.\"\"\"\n objs = []\n cur = self\n while cur.parent is not None:\n if cur in objs:\n raise ValidationError(_(\"You can not make loops in clubs\"))\n objs.append(cur)\n cur = cur.parent\n
"},{"location":"reference/club/models/#club.models.Club.is_owned_by","title":"is_owned_by(user)
","text":"Method to see if that object can be super edited by the given user.
Source code inclub/models.py
def is_owned_by(self, user):\n \"\"\"Method to see if that object can be super edited by the given user.\"\"\"\n if user.is_anonymous:\n return False\n return user.is_board_member\n
"},{"location":"reference/club/models/#club.models.Club.can_be_edited_by","title":"can_be_edited_by(user)
","text":"Method to see if that object can be edited by the given user.
Source code inclub/models.py
def can_be_edited_by(self, user):\n \"\"\"Method to see if that object can be edited by the given user.\"\"\"\n return self.has_rights_in_club(user)\n
"},{"location":"reference/club/models/#club.models.Club.can_be_viewed_by","title":"can_be_viewed_by(user)
","text":"Method to see if that object can be seen by the given user.
Source code inclub/models.py
def can_be_viewed_by(self, user):\n \"\"\"Method to see if that object can be seen by the given user.\"\"\"\n sub = User.objects.filter(pk=user.pk).first()\n if sub is None:\n return False\n return sub.was_subscribed\n
"},{"location":"reference/club/models/#club.models.Club.get_membership_for","title":"get_membership_for(user)
","text":"Return the current membership the given user.
NoteThe result is cached.
Source code inclub/models.py
def get_membership_for(self, user: User) -> Membership | None:\n \"\"\"Return the current membership the given user.\n\n Note:\n The result is cached.\n \"\"\"\n if user.is_anonymous:\n return None\n membership = cache.get(f\"membership_{self.id}_{user.id}\")\n if membership == \"not_member\":\n return None\n if membership is None:\n membership = self.members.filter(user=user, end_date=None).first()\n if membership is None:\n cache.set(f\"membership_{self.id}_{user.id}\", \"not_member\")\n else:\n cache.set(f\"membership_{self.id}_{user.id}\", membership)\n return membership\n
"},{"location":"reference/club/models/#club.models.MembershipQuerySet","title":"MembershipQuerySet
","text":" Bases: QuerySet
ongoing()
","text":"Filter all memberships which are not finished yet.
Source code inclub/models.py
def ongoing(self) -> Self:\n \"\"\"Filter all memberships which are not finished yet.\"\"\"\n return self.filter(Q(end_date=None) | Q(end_date__gt=localdate()))\n
"},{"location":"reference/club/models/#club.models.MembershipQuerySet.board","title":"board()
","text":"Filter all memberships where the user is/was in the board.
Be aware that users who were in the board in the past are included, even if there are no more members.
If you want to get the users who are currently in the board, mind combining this with the :meth:ongoing
queryset method
club/models.py
def board(self) -> Self:\n \"\"\"Filter all memberships where the user is/was in the board.\n\n Be aware that users who were in the board in the past\n are included, even if there are no more members.\n\n If you want to get the users who are currently in the board,\n mind combining this with the :meth:`ongoing` queryset method\n \"\"\"\n return self.filter(role__gt=settings.SITH_MAXIMUM_FREE_ROLE)\n
"},{"location":"reference/club/models/#club.models.MembershipQuerySet.update","title":"update(**kwargs)
","text":"Refresh the cache for the elements of the queryset.
Besides that, does the same job as a regular update method.
Be aware that this adds a db query to retrieve the updated objects
Source code inclub/models.py
def update(self, **kwargs):\n \"\"\"Refresh the cache for the elements of the queryset.\n\n Besides that, does the same job as a regular update method.\n\n Be aware that this adds a db query to retrieve the updated objects\n \"\"\"\n nb_rows = super().update(**kwargs)\n if nb_rows > 0:\n # if at least a row was affected, refresh the cache\n for membership in self.all():\n if membership.end_date is not None:\n cache.set(\n f\"membership_{membership.club_id}_{membership.user_id}\",\n \"not_member\",\n )\n else:\n cache.set(\n f\"membership_{membership.club_id}_{membership.user_id}\",\n membership,\n )\n
"},{"location":"reference/club/models/#club.models.MembershipQuerySet.delete","title":"delete()
","text":"Work just like the default Django's delete() method, but add a cache invalidation for the elements of the queryset before the deletion.
Be aware that this adds a db query to retrieve the deleted element. As this first query take place before the deletion operation, it will be performed even if the deletion fails.
Source code inclub/models.py
def delete(self):\n \"\"\"Work just like the default Django's delete() method,\n but add a cache invalidation for the elements of the queryset\n before the deletion.\n\n Be aware that this adds a db query to retrieve the deleted element.\n As this first query take place before the deletion operation,\n it will be performed even if the deletion fails.\n \"\"\"\n ids = list(self.values_list(\"club_id\", \"user_id\"))\n nb_rows, _ = super().delete()\n if nb_rows > 0:\n for club_id, user_id in ids:\n cache.set(f\"membership_{club_id}_{user_id}\", \"not_member\")\n
"},{"location":"reference/club/models/#club.models.Membership","title":"Membership
","text":" Bases: Model
The Membership class makes the connection between User and Clubs.
Both Users and Clubs can have many Membership objectsA User is currently member of all the Clubs where its Membership has an end_date set to null/None. Otherwise, it's a past membership kept because it can be very useful to see who was in which Club in the past.
"},{"location":"reference/club/models/#club.models.Membership.is_owned_by","title":"is_owned_by(user)
","text":"Method to see if that object can be super edited by the given user.
Source code inclub/models.py
def is_owned_by(self, user):\n \"\"\"Method to see if that object can be super edited by the given user.\"\"\"\n if user.is_anonymous:\n return False\n return user.is_board_member\n
"},{"location":"reference/club/models/#club.models.Membership.can_be_edited_by","title":"can_be_edited_by(user)
","text":"Check if that object can be edited by the given user.
Source code inclub/models.py
def can_be_edited_by(self, user: User) -> bool:\n \"\"\"Check if that object can be edited by the given user.\"\"\"\n if user.is_root or user.is_board_member:\n return True\n membership = self.club.get_membership_for(user)\n return membership is not None and membership.role >= self.role\n
"},{"location":"reference/club/models/#club.models.Mailing","title":"Mailing
","text":" Bases: Model
A Mailing list for a club.
WarningRemember that mailing lists should be validated by UTBM.
"},{"location":"reference/club/models/#club.models.MailingSubscription","title":"MailingSubscription
","text":" Bases: Model
Link between user and mailing list.
"},{"location":"reference/club/models/#club.models.get_default_owner_group","title":"get_default_owner_group()
","text":"Source code in club/models.py
def get_default_owner_group():\n return settings.SITH_GROUP_ROOT_ID\n
"},{"location":"reference/club/views/","title":"Views","text":""},{"location":"reference/club/views/#club.views.ClubEditForm","title":"ClubEditForm(*args, **kwargs)
","text":" Bases: ModelForm
club/forms.py
def __init__(self, *args, **kwargs):\n super().__init__(*args, **kwargs)\n self.fields[\"short_description\"].widget = forms.Textarea()\n
"},{"location":"reference/club/views/#club.views.ClubMemberForm","title":"ClubMemberForm(*args, **kwargs)
","text":" Bases: Form
Form handling the members of a club.
Source code inclub/forms.py
def __init__(self, *args, **kwargs):\n self.club = kwargs.pop(\"club\")\n self.request_user = kwargs.pop(\"request_user\")\n self.club_members = kwargs.pop(\"club_members\", None)\n if not self.club_members:\n self.club_members = (\n self.club.members.filter(end_date=None).order_by(\"-role\").all()\n )\n self.request_user_membership = self.club.get_membership_for(self.request_user)\n super().__init__(*args, **kwargs)\n\n # Using a ModelForm binds too much the form with the model and we don't want that\n # We want the view to process the model creation since they are multiple users\n # We also want the form to handle bulk deletion\n self.fields.update(\n forms.fields_for_model(\n Membership,\n fields=(\"role\", \"start_date\", \"description\"),\n widgets={\"start_date\": SelectDate},\n )\n )\n\n # Role is required only if users is specified\n self.fields[\"role\"].required = False\n\n # Start date and description are never really required\n self.fields[\"start_date\"].required = False\n self.fields[\"description\"].required = False\n\n self.fields[\"users_old\"] = forms.ModelMultipleChoiceField(\n User.objects.filter(\n id__in=[\n ms.user.id\n for ms in self.club_members\n if ms.can_be_edited_by(self.request_user)\n ]\n ).all(),\n label=_(\"Mark as old\"),\n required=False,\n widget=forms.CheckboxSelectMultiple,\n )\n if not self.request_user.is_root:\n self.fields.pop(\"start_date\")\n
"},{"location":"reference/club/views/#club.views.ClubMemberForm.clean_users","title":"clean_users()
","text":"Check that the user is not trying to add an user already in the club.
Also check that the user is valid and has a valid subscription.
Source code inclub/forms.py
def clean_users(self):\n \"\"\"Check that the user is not trying to add an user already in the club.\n\n Also check that the user is valid and has a valid subscription.\n \"\"\"\n cleaned_data = super().clean()\n users = []\n for user in cleaned_data[\"users\"]:\n if not user.is_subscribed:\n raise forms.ValidationError(\n _(\"User must be subscriber to take part to a club\"), code=\"invalid\"\n )\n if self.club.get_membership_for(user):\n raise forms.ValidationError(\n _(\"You can not add the same user twice\"), code=\"invalid\"\n )\n users.append(user)\n return users\n
"},{"location":"reference/club/views/#club.views.ClubMemberForm.clean","title":"clean()
","text":"Check user rights for adding an user.
Source code inclub/forms.py
def clean(self):\n \"\"\"Check user rights for adding an user.\"\"\"\n cleaned_data = super().clean()\n\n if \"start_date\" in cleaned_data and not cleaned_data[\"start_date\"]:\n # Drop start_date if allowed to edition but not specified\n cleaned_data.pop(\"start_date\")\n\n if not cleaned_data.get(\"users\"):\n # No user to add equals no check needed\n return cleaned_data\n\n if cleaned_data.get(\"role\", \"\") == \"\":\n # Role is required if users exists\n self.add_error(\"role\", _(\"You should specify a role\"))\n return cleaned_data\n\n request_user = self.request_user\n membership = self.request_user_membership\n if not (\n cleaned_data[\"role\"] <= settings.SITH_MAXIMUM_FREE_ROLE\n or (membership is not None and membership.role >= cleaned_data[\"role\"])\n or request_user.is_board_member\n or request_user.is_root\n ):\n raise forms.ValidationError(_(\"You do not have the permission to do that\"))\n return cleaned_data\n
"},{"location":"reference/club/views/#club.views.MailingForm","title":"MailingForm(club_id, user_id, mailings, *args, **kwargs)
","text":" Bases: Form
Form handling mailing lists right.
Source code inclub/forms.py
def __init__(self, club_id, user_id, mailings, *args, **kwargs):\n super().__init__(*args, **kwargs)\n\n self.fields[\"action\"] = forms.TypedChoiceField(\n choices=(\n (self.ACTION_NEW_MAILING, _(\"New Mailing\")),\n (self.ACTION_NEW_SUBSCRIPTION, _(\"Subscribe\")),\n (self.ACTION_REMOVE_SUBSCRIPTION, _(\"Remove\")),\n ),\n coerce=int,\n label=_(\"Action\"),\n initial=1,\n required=True,\n widget=forms.HiddenInput(),\n )\n\n # Generate bulk removal forms, they are never required\n for mailing in mailings:\n self.fields[\"removal_\" + str(mailing.id)] = forms.ModelMultipleChoiceField(\n mailing.subscriptions.all(),\n label=_(\"Remove\"),\n required=False,\n widget=forms.CheckboxSelectMultiple,\n )\n\n # Include fields for handling mailing creation\n mailing_fields = (\"email\",)\n self.fields.update(forms.fields_for_model(Mailing, fields=mailing_fields))\n for field in mailing_fields:\n self.fields[\"mailing_\" + field] = self.fields.pop(field)\n self.fields[\"mailing_\" + field].required = False\n\n # Include fields for handling subscription creation\n subscription_fields = (\"mailing\", \"email\")\n self.fields.update(\n forms.fields_for_model(MailingSubscription, fields=subscription_fields)\n )\n for field in subscription_fields:\n self.fields[\"subscription_\" + field] = self.fields.pop(field)\n self.fields[\"subscription_\" + field].required = False\n\n self.fields[\"subscription_mailing\"].queryset = Mailing.objects.filter(\n club__id=club_id, is_moderated=True\n )\n
"},{"location":"reference/club/views/#club.views.MailingForm.check_required","title":"check_required(cleaned_data, field)
","text":"If the given field doesn't exist or has no value, add a required error on it.
Source code inclub/forms.py
def check_required(self, cleaned_data, field):\n \"\"\"If the given field doesn't exist or has no value, add a required error on it.\"\"\"\n if not cleaned_data.get(field, None):\n self.add_error(field, _(\"This field is required\"))\n
"},{"location":"reference/club/views/#club.views.MailingForm.clean_subscription_users","title":"clean_subscription_users()
","text":"Convert given users into real users and check their validity.
Source code inclub/forms.py
def clean_subscription_users(self):\n \"\"\"Convert given users into real users and check their validity.\"\"\"\n cleaned_data = super().clean()\n users = []\n for user in cleaned_data[\"subscription_users\"]:\n if not user.email:\n raise forms.ValidationError(\n _(\"One of the selected users doesn't have an email address\"),\n code=\"invalid\",\n )\n users.append(user)\n return users\n
"},{"location":"reference/club/views/#club.views.SellingsForm","title":"SellingsForm(club, *args, **kwargs)
","text":" Bases: Form
club/forms.py
def __init__(self, club, *args, **kwargs):\n super().__init__(*args, **kwargs)\n self.fields[\"products\"] = forms.ModelMultipleChoiceField(\n club.products.order_by(\"name\").filter(archived=False).all(),\n label=_(\"Products\"),\n required=False,\n )\n self.fields[\"archived_products\"] = forms.ModelMultipleChoiceField(\n club.products.order_by(\"name\").filter(archived=True).all(),\n label=_(\"Archived products\"),\n required=False,\n )\n
"},{"location":"reference/club/views/#club.views.Club","title":"Club
","text":" Bases: Model
The Club class, made as a tree to allow nice tidy organization.
"},{"location":"reference/club/views/#club.views.Club.check_loop","title":"check_loop()
","text":"Raise a validation error when a loop is found within the parent list.
Source code inclub/models.py
def check_loop(self):\n \"\"\"Raise a validation error when a loop is found within the parent list.\"\"\"\n objs = []\n cur = self\n while cur.parent is not None:\n if cur in objs:\n raise ValidationError(_(\"You can not make loops in clubs\"))\n objs.append(cur)\n cur = cur.parent\n
"},{"location":"reference/club/views/#club.views.Club.is_owned_by","title":"is_owned_by(user)
","text":"Method to see if that object can be super edited by the given user.
Source code inclub/models.py
def is_owned_by(self, user):\n \"\"\"Method to see if that object can be super edited by the given user.\"\"\"\n if user.is_anonymous:\n return False\n return user.is_board_member\n
"},{"location":"reference/club/views/#club.views.Club.can_be_edited_by","title":"can_be_edited_by(user)
","text":"Method to see if that object can be edited by the given user.
Source code inclub/models.py
def can_be_edited_by(self, user):\n \"\"\"Method to see if that object can be edited by the given user.\"\"\"\n return self.has_rights_in_club(user)\n
"},{"location":"reference/club/views/#club.views.Club.can_be_viewed_by","title":"can_be_viewed_by(user)
","text":"Method to see if that object can be seen by the given user.
Source code inclub/models.py
def can_be_viewed_by(self, user):\n \"\"\"Method to see if that object can be seen by the given user.\"\"\"\n sub = User.objects.filter(pk=user.pk).first()\n if sub is None:\n return False\n return sub.was_subscribed\n
"},{"location":"reference/club/views/#club.views.Club.get_membership_for","title":"get_membership_for(user)
","text":"Return the current membership the given user.
NoteThe result is cached.
Source code inclub/models.py
def get_membership_for(self, user: User) -> Membership | None:\n \"\"\"Return the current membership the given user.\n\n Note:\n The result is cached.\n \"\"\"\n if user.is_anonymous:\n return None\n membership = cache.get(f\"membership_{self.id}_{user.id}\")\n if membership == \"not_member\":\n return None\n if membership is None:\n membership = self.members.filter(user=user, end_date=None).first()\n if membership is None:\n cache.set(f\"membership_{self.id}_{user.id}\", \"not_member\")\n else:\n cache.set(f\"membership_{self.id}_{user.id}\", membership)\n return membership\n
"},{"location":"reference/club/views/#club.views.Mailing","title":"Mailing
","text":" Bases: Model
A Mailing list for a club.
WarningRemember that mailing lists should be validated by UTBM.
"},{"location":"reference/club/views/#club.views.MailingSubscription","title":"MailingSubscription
","text":" Bases: Model
Link between user and mailing list.
"},{"location":"reference/club/views/#club.views.Membership","title":"Membership
","text":" Bases: Model
The Membership class makes the connection between User and Clubs.
Both Users and Clubs can have many Membership objectsA User is currently member of all the Clubs where its Membership has an end_date set to null/None. Otherwise, it's a past membership kept because it can be very useful to see who was in which Club in the past.
"},{"location":"reference/club/views/#club.views.Membership.is_owned_by","title":"is_owned_by(user)
","text":"Method to see if that object can be super edited by the given user.
Source code inclub/models.py
def is_owned_by(self, user):\n \"\"\"Method to see if that object can be super edited by the given user.\"\"\"\n if user.is_anonymous:\n return False\n return user.is_board_member\n
"},{"location":"reference/club/views/#club.views.Membership.can_be_edited_by","title":"can_be_edited_by(user)
","text":"Check if that object can be edited by the given user.
Source code inclub/models.py
def can_be_edited_by(self, user: User) -> bool:\n \"\"\"Check if that object can be edited by the given user.\"\"\"\n if user.is_root or user.is_board_member:\n return True\n membership = self.club.get_membership_for(user)\n return membership is not None and membership.role >= self.role\n
"},{"location":"reference/club/views/#club.views.ClubTabsMixin","title":"ClubTabsMixin
","text":" Bases: TabedViewMixin
ClubListView
","text":" Bases: ListView
List the Clubs.
"},{"location":"reference/club/views/#club.views.ClubView","title":"ClubView
","text":" Bases: ClubTabsMixin
, DetailView
Front page of a Club.
"},{"location":"reference/club/views/#club.views.ClubRevView","title":"ClubRevView
","text":" Bases: ClubView
Display a specific page revision.
"},{"location":"reference/club/views/#club.views.ClubPageEditView","title":"ClubPageEditView
","text":" Bases: ClubTabsMixin
, PageEditViewBase
ClubPageHistView
","text":" Bases: ClubTabsMixin
, CanViewMixin
, DetailView
Modification hostory of the page.
"},{"location":"reference/club/views/#club.views.ClubToolsView","title":"ClubToolsView
","text":" Bases: ClubTabsMixin
, CanEditMixin
, DetailView
Tools page of a Club.
"},{"location":"reference/club/views/#club.views.ClubMembersView","title":"ClubMembersView
","text":" Bases: ClubTabsMixin
, CanViewMixin
, DetailFormView
View of a club's members.
"},{"location":"reference/club/views/#club.views.ClubMembersView.form_valid","title":"form_valid(form)
","text":"Check user rights.
Source code inclub/views.py
def form_valid(self, form):\n \"\"\"Check user rights.\"\"\"\n resp = super().form_valid(form)\n\n data = form.clean()\n users = data.pop(\"users\", [])\n users_old = data.pop(\"users_old\", [])\n for user in users:\n Membership(club=self.get_object(), user=user, **data).save()\n for user in users_old:\n membership = self.get_object().get_membership_for(user)\n membership.end_date = timezone.now()\n membership.save()\n return resp\n
"},{"location":"reference/club/views/#club.views.ClubOldMembersView","title":"ClubOldMembersView
","text":" Bases: ClubTabsMixin
, CanViewMixin
, DetailView
Old members of a club.
"},{"location":"reference/club/views/#club.views.ClubSellingView","title":"ClubSellingView
","text":" Bases: ClubTabsMixin
, CanEditMixin
, DetailFormView
Sellings of a club.
"},{"location":"reference/club/views/#club.views.ClubSellingCSVView","title":"ClubSellingCSVView
","text":" Bases: ClubSellingView
Generate sellings in csv for a given period.
"},{"location":"reference/club/views/#club.views.ClubSellingCSVView.StreamWriter","title":"StreamWriter
","text":"Implements a file-like interface for streaming the CSV.
"},{"location":"reference/club/views/#club.views.ClubSellingCSVView.StreamWriter.write","title":"write(value)
","text":"Write the value by returning it, instead of storing in a buffer.
Source code inclub/views.py
def write(self, value):\n \"\"\"Write the value by returning it, instead of storing in a buffer.\"\"\"\n return value\n
"},{"location":"reference/club/views/#club.views.ClubEditView","title":"ClubEditView
","text":" Bases: ClubTabsMixin
, CanEditMixin
, UpdateView
Edit a Club's main informations (for the club's members).
"},{"location":"reference/club/views/#club.views.ClubEditPropView","title":"ClubEditPropView
","text":" Bases: ClubTabsMixin
, CanEditPropMixin
, UpdateView
Edit the properties of a Club object (for the Sith admins).
"},{"location":"reference/club/views/#club.views.ClubCreateView","title":"ClubCreateView
","text":" Bases: CanCreateMixin
, CreateView
Create a club (for the Sith admin).
"},{"location":"reference/club/views/#club.views.MembershipSetOldView","title":"MembershipSetOldView
","text":" Bases: CanEditMixin
, DetailView
Set a membership as beeing old.
"},{"location":"reference/club/views/#club.views.MembershipDeleteView","title":"MembershipDeleteView
","text":" Bases: UserIsRootMixin
, DeleteView
Delete a membership (for admins only).
"},{"location":"reference/club/views/#club.views.ClubStatView","title":"ClubStatView
","text":" Bases: TemplateView
ClubMailingView
","text":" Bases: ClubTabsMixin
, CanEditMixin
, DetailFormView
A list of mailing for a given club.
"},{"location":"reference/club/views/#club.views.ClubMailingView.add_new_mailing","title":"add_new_mailing(cleaned_data)
","text":"Create a new mailing list from the form.
Source code inclub/views.py
def add_new_mailing(self, cleaned_data) -> ValidationError | None:\n \"\"\"Create a new mailing list from the form.\"\"\"\n mailing = Mailing(\n club=self.get_object(),\n email=cleaned_data[\"mailing_email\"],\n moderator=self.request.user,\n is_moderated=False,\n )\n try:\n mailing.clean()\n except ValidationError as validation_error:\n return validation_error\n mailing.save()\n return None\n
"},{"location":"reference/club/views/#club.views.ClubMailingView.add_new_subscription","title":"add_new_subscription(cleaned_data)
","text":"Add mailing subscriptions for each user given and/or for the specified email in form.
Source code inclub/views.py
def add_new_subscription(self, cleaned_data) -> ValidationError | None:\n \"\"\"Add mailing subscriptions for each user given and/or for the specified email in form.\"\"\"\n users_to_save = []\n\n for user in cleaned_data[\"subscription_users\"]:\n sub = MailingSubscription(\n mailing=cleaned_data[\"subscription_mailing\"], user=user\n )\n try:\n sub.clean()\n except ValidationError as validation_error:\n return validation_error\n\n sub.save()\n users_to_save.append(sub)\n\n if cleaned_data[\"subscription_email\"]:\n sub = MailingSubscription(\n mailing=cleaned_data[\"subscription_mailing\"],\n email=cleaned_data[\"subscription_email\"],\n )\n\n try:\n sub.clean()\n except ValidationError as validation_error:\n return validation_error\n sub.save()\n\n # Save users after we are sure there is no error\n for user in users_to_save:\n user.save()\n\n return None\n
"},{"location":"reference/club/views/#club.views.ClubMailingView.remove_subscription","title":"remove_subscription(cleaned_data)
","text":"Remove specified users from a mailing list.
Source code inclub/views.py
def remove_subscription(self, cleaned_data):\n \"\"\"Remove specified users from a mailing list.\"\"\"\n fields = [\n val for key, val in cleaned_data.items() if key.startswith(\"removal_\")\n ]\n for field in fields:\n for sub in field:\n sub.delete()\n
"},{"location":"reference/club/views/#club.views.MailingDeleteView","title":"MailingDeleteView
","text":" Bases: CanEditMixin
, DeleteView
MailingSubscriptionDeleteView
","text":" Bases: CanEditMixin
, DeleteView
MailingAutoGenerationView
","text":" Bases: View
PosterListView
","text":" Bases: ClubTabsMixin
, PosterListBaseView
, CanViewMixin
List communication posters.
"},{"location":"reference/club/views/#club.views.PosterCreateView","title":"PosterCreateView
","text":" Bases: PosterCreateBaseView
, CanCreateMixin
Create communication poster.
"},{"location":"reference/club/views/#club.views.PosterEditView","title":"PosterEditView
","text":" Bases: ClubTabsMixin
, PosterEditBaseView
, CanEditMixin
Edit communication poster.
"},{"location":"reference/club/views/#club.views.PosterDeleteView","title":"PosterDeleteView
","text":" Bases: PosterDeleteBaseView
, ClubTabsMixin
, CanEditMixin
Delete communication poster.
"},{"location":"reference/com/models/","title":"Models","text":""},{"location":"reference/com/models/#com.models.NEWS_TYPES","title":"NEWS_TYPES = [('NOTICE', _('Notice')), ('EVENT', _('Event')), ('WEEKLY', _('Weekly')), ('CALL', _('Call'))]
module-attribute
","text":""},{"location":"reference/com/models/#com.models.Club","title":"Club
","text":" Bases: Model
The Club class, made as a tree to allow nice tidy organization.
"},{"location":"reference/com/models/#com.models.Club.check_loop","title":"check_loop()
","text":"Raise a validation error when a loop is found within the parent list.
Source code inclub/models.py
def check_loop(self):\n \"\"\"Raise a validation error when a loop is found within the parent list.\"\"\"\n objs = []\n cur = self\n while cur.parent is not None:\n if cur in objs:\n raise ValidationError(_(\"You can not make loops in clubs\"))\n objs.append(cur)\n cur = cur.parent\n
"},{"location":"reference/com/models/#com.models.Club.is_owned_by","title":"is_owned_by(user)
","text":"Method to see if that object can be super edited by the given user.
Source code inclub/models.py
def is_owned_by(self, user):\n \"\"\"Method to see if that object can be super edited by the given user.\"\"\"\n if user.is_anonymous:\n return False\n return user.is_board_member\n
"},{"location":"reference/com/models/#com.models.Club.can_be_edited_by","title":"can_be_edited_by(user)
","text":"Method to see if that object can be edited by the given user.
Source code inclub/models.py
def can_be_edited_by(self, user):\n \"\"\"Method to see if that object can be edited by the given user.\"\"\"\n return self.has_rights_in_club(user)\n
"},{"location":"reference/com/models/#com.models.Club.can_be_viewed_by","title":"can_be_viewed_by(user)
","text":"Method to see if that object can be seen by the given user.
Source code inclub/models.py
def can_be_viewed_by(self, user):\n \"\"\"Method to see if that object can be seen by the given user.\"\"\"\n sub = User.objects.filter(pk=user.pk).first()\n if sub is None:\n return False\n return sub.was_subscribed\n
"},{"location":"reference/com/models/#com.models.Club.get_membership_for","title":"get_membership_for(user)
","text":"Return the current membership the given user.
NoteThe result is cached.
Source code inclub/models.py
def get_membership_for(self, user: User) -> Membership | None:\n \"\"\"Return the current membership the given user.\n\n Note:\n The result is cached.\n \"\"\"\n if user.is_anonymous:\n return None\n membership = cache.get(f\"membership_{self.id}_{user.id}\")\n if membership == \"not_member\":\n return None\n if membership is None:\n membership = self.members.filter(user=user, end_date=None).first()\n if membership is None:\n cache.set(f\"membership_{self.id}_{user.id}\", \"not_member\")\n else:\n cache.set(f\"membership_{self.id}_{user.id}\", membership)\n return membership\n
"},{"location":"reference/com/models/#com.models.Sith","title":"Sith
","text":" Bases: Model
A one instance class storing all the modifiable infos.
"},{"location":"reference/com/models/#com.models.News","title":"News
","text":" Bases: Model
The news class.
"},{"location":"reference/com/models/#com.models.NewsDate","title":"NewsDate
","text":" Bases: Model
A date class, useful for weekly events, or for events that just have no date.
This class allows more flexibilty managing the dates related to a news, particularly when this news is weekly, since we don't have to make copies
"},{"location":"reference/com/models/#com.models.Weekmail","title":"Weekmail
","text":" Bases: Model
The weekmail class.
:ivar title: Title of the weekmail :ivar intro: Introduction of the weekmail :ivar joke: Joke of the week :ivar protip: Tip of the week :ivar conclusion: Conclusion of the weekmail :ivar sent: Track if the weekmail has been sent
"},{"location":"reference/com/models/#com.models.Weekmail.send","title":"send()
","text":"Send the weekmail to all users with the receive weekmail option opt-in.
Also send the weekmail to the mailing list in settings.SITH_COM_EMAIL.
Source code incom/models.py
def send(self):\n \"\"\"Send the weekmail to all users with the receive weekmail option opt-in.\n\n Also send the weekmail to the mailing list in settings.SITH_COM_EMAIL.\n \"\"\"\n dest = [\n i[0]\n for i in Preferences.objects.filter(receive_weekmail=True).values_list(\n \"user__email\"\n )\n ]\n with transaction.atomic():\n email = EmailMultiAlternatives(\n subject=self.title,\n body=self.render_text(),\n from_email=settings.SITH_COM_EMAIL,\n to=Sith.objects.first().weekmail_destinations.split(\" \"),\n bcc=dest,\n )\n email.attach_alternative(self.render_html(), \"text/html\")\n email.send()\n self.sent = True\n self.save()\n Weekmail().save()\n
"},{"location":"reference/com/models/#com.models.Weekmail.render_text","title":"render_text()
","text":"Renders a pure text version of the mail for readers without HTML support.
Source code incom/models.py
def render_text(self):\n \"\"\"Renders a pure text version of the mail for readers without HTML support.\"\"\"\n return render(\n None, \"com/weekmail_renderer_text.jinja\", context={\"weekmail\": self}\n ).content.decode(\"utf-8\")\n
"},{"location":"reference/com/models/#com.models.Weekmail.render_html","title":"render_html()
","text":"Renders an HTML version of the mail with images and fancy CSS.
Source code incom/models.py
def render_html(self):\n \"\"\"Renders an HTML version of the mail with images and fancy CSS.\"\"\"\n return render(\n None, \"com/weekmail_renderer_html.jinja\", context={\"weekmail\": self}\n ).content.decode(\"utf-8\")\n
"},{"location":"reference/com/models/#com.models.Weekmail.get_banner","title":"get_banner()
","text":"Return an absolute link to the banner.
Source code incom/models.py
def get_banner(self):\n \"\"\"Return an absolute link to the banner.\"\"\"\n return (\n \"http://\" + settings.SITH_URL + static(\"com/img/weekmail_bannerV2P22.png\")\n )\n
"},{"location":"reference/com/models/#com.models.Weekmail.get_footer","title":"get_footer()
","text":"Return an absolute link to the footer.
Source code incom/models.py
def get_footer(self):\n \"\"\"Return an absolute link to the footer.\"\"\"\n return \"http://\" + settings.SITH_URL + static(\"com/img/weekmail_footerP22.png\")\n
"},{"location":"reference/com/models/#com.models.WeekmailArticle","title":"WeekmailArticle
","text":" Bases: Model
Screen
","text":" Bases: Model
Poster
","text":" Bases: Model
news_notification_callback(notif)
","text":"Source code in com/models.py
def news_notification_callback(notif):\n count = (\n News.objects.filter(\n Q(dates__start_date__gt=timezone.now(), is_moderated=False)\n | Q(type=\"NOTICE\", is_moderated=False)\n )\n .distinct()\n .count()\n )\n if count:\n notif.viewed = False\n notif.param = \"%s\" % count\n notif.date = timezone.now()\n else:\n notif.viewed = True\n
"},{"location":"reference/com/views/","title":"Views","text":""},{"location":"reference/com/views/#com.views.sith","title":"sith = Sith.objects.first
module-attribute
","text":""},{"location":"reference/com/views/#com.views.Club","title":"Club
","text":" Bases: Model
The Club class, made as a tree to allow nice tidy organization.
"},{"location":"reference/com/views/#com.views.Club.check_loop","title":"check_loop()
","text":"Raise a validation error when a loop is found within the parent list.
Source code inclub/models.py
def check_loop(self):\n \"\"\"Raise a validation error when a loop is found within the parent list.\"\"\"\n objs = []\n cur = self\n while cur.parent is not None:\n if cur in objs:\n raise ValidationError(_(\"You can not make loops in clubs\"))\n objs.append(cur)\n cur = cur.parent\n
"},{"location":"reference/com/views/#com.views.Club.is_owned_by","title":"is_owned_by(user)
","text":"Method to see if that object can be super edited by the given user.
Source code inclub/models.py
def is_owned_by(self, user):\n \"\"\"Method to see if that object can be super edited by the given user.\"\"\"\n if user.is_anonymous:\n return False\n return user.is_board_member\n
"},{"location":"reference/com/views/#com.views.Club.can_be_edited_by","title":"can_be_edited_by(user)
","text":"Method to see if that object can be edited by the given user.
Source code inclub/models.py
def can_be_edited_by(self, user):\n \"\"\"Method to see if that object can be edited by the given user.\"\"\"\n return self.has_rights_in_club(user)\n
"},{"location":"reference/com/views/#com.views.Club.can_be_viewed_by","title":"can_be_viewed_by(user)
","text":"Method to see if that object can be seen by the given user.
Source code inclub/models.py
def can_be_viewed_by(self, user):\n \"\"\"Method to see if that object can be seen by the given user.\"\"\"\n sub = User.objects.filter(pk=user.pk).first()\n if sub is None:\n return False\n return sub.was_subscribed\n
"},{"location":"reference/com/views/#com.views.Club.get_membership_for","title":"get_membership_for(user)
","text":"Return the current membership the given user.
NoteThe result is cached.
Source code inclub/models.py
def get_membership_for(self, user: User) -> Membership | None:\n \"\"\"Return the current membership the given user.\n\n Note:\n The result is cached.\n \"\"\"\n if user.is_anonymous:\n return None\n membership = cache.get(f\"membership_{self.id}_{user.id}\")\n if membership == \"not_member\":\n return None\n if membership is None:\n membership = self.members.filter(user=user, end_date=None).first()\n if membership is None:\n cache.set(f\"membership_{self.id}_{user.id}\", \"not_member\")\n else:\n cache.set(f\"membership_{self.id}_{user.id}\", membership)\n return membership\n
"},{"location":"reference/com/views/#com.views.Mailing","title":"Mailing
","text":" Bases: Model
A Mailing list for a club.
WarningRemember that mailing lists should be validated by UTBM.
"},{"location":"reference/com/views/#com.views.News","title":"News
","text":" Bases: Model
The news class.
"},{"location":"reference/com/views/#com.views.NewsDate","title":"NewsDate
","text":" Bases: Model
A date class, useful for weekly events, or for events that just have no date.
This class allows more flexibilty managing the dates related to a news, particularly when this news is weekly, since we don't have to make copies
"},{"location":"reference/com/views/#com.views.Poster","title":"Poster
","text":" Bases: Model
Screen
","text":" Bases: Model
Sith
","text":" Bases: Model
A one instance class storing all the modifiable infos.
"},{"location":"reference/com/views/#com.views.Weekmail","title":"Weekmail
","text":" Bases: Model
The weekmail class.
:ivar title: Title of the weekmail :ivar intro: Introduction of the weekmail :ivar joke: Joke of the week :ivar protip: Tip of the week :ivar conclusion: Conclusion of the weekmail :ivar sent: Track if the weekmail has been sent
"},{"location":"reference/com/views/#com.views.Weekmail.send","title":"send()
","text":"Send the weekmail to all users with the receive weekmail option opt-in.
Also send the weekmail to the mailing list in settings.SITH_COM_EMAIL.
Source code incom/models.py
def send(self):\n \"\"\"Send the weekmail to all users with the receive weekmail option opt-in.\n\n Also send the weekmail to the mailing list in settings.SITH_COM_EMAIL.\n \"\"\"\n dest = [\n i[0]\n for i in Preferences.objects.filter(receive_weekmail=True).values_list(\n \"user__email\"\n )\n ]\n with transaction.atomic():\n email = EmailMultiAlternatives(\n subject=self.title,\n body=self.render_text(),\n from_email=settings.SITH_COM_EMAIL,\n to=Sith.objects.first().weekmail_destinations.split(\" \"),\n bcc=dest,\n )\n email.attach_alternative(self.render_html(), \"text/html\")\n email.send()\n self.sent = True\n self.save()\n Weekmail().save()\n
"},{"location":"reference/com/views/#com.views.Weekmail.render_text","title":"render_text()
","text":"Renders a pure text version of the mail for readers without HTML support.
Source code incom/models.py
def render_text(self):\n \"\"\"Renders a pure text version of the mail for readers without HTML support.\"\"\"\n return render(\n None, \"com/weekmail_renderer_text.jinja\", context={\"weekmail\": self}\n ).content.decode(\"utf-8\")\n
"},{"location":"reference/com/views/#com.views.Weekmail.render_html","title":"render_html()
","text":"Renders an HTML version of the mail with images and fancy CSS.
Source code incom/models.py
def render_html(self):\n \"\"\"Renders an HTML version of the mail with images and fancy CSS.\"\"\"\n return render(\n None, \"com/weekmail_renderer_html.jinja\", context={\"weekmail\": self}\n ).content.decode(\"utf-8\")\n
"},{"location":"reference/com/views/#com.views.Weekmail.get_banner","title":"get_banner()
","text":"Return an absolute link to the banner.
Source code incom/models.py
def get_banner(self):\n \"\"\"Return an absolute link to the banner.\"\"\"\n return (\n \"http://\" + settings.SITH_URL + static(\"com/img/weekmail_bannerV2P22.png\")\n )\n
"},{"location":"reference/com/views/#com.views.Weekmail.get_footer","title":"get_footer()
","text":"Return an absolute link to the footer.
Source code incom/models.py
def get_footer(self):\n \"\"\"Return an absolute link to the footer.\"\"\"\n return \"http://\" + settings.SITH_URL + static(\"com/img/weekmail_footerP22.png\")\n
"},{"location":"reference/com/views/#com.views.WeekmailArticle","title":"WeekmailArticle
","text":" Bases: Model
PosterForm(*args, **kwargs)
","text":" Bases: ModelForm
com/views.py
def __init__(self, *args, **kwargs):\n self.user = kwargs.pop(\"user\", None)\n super().__init__(*args, **kwargs)\n if self.user and not self.user.is_com_admin:\n self.fields[\"club\"].queryset = Club.objects.filter(\n id__in=self.user.clubs_with_rights\n )\n self.fields.pop(\"display_time\")\n
"},{"location":"reference/com/views/#com.views.ComTabsMixin","title":"ComTabsMixin
","text":" Bases: TabedViewMixin
IsComAdminMixin
","text":" Bases: View
ComEditView
","text":" Bases: ComTabsMixin
, CanEditPropMixin
, UpdateView
AlertMsgEditView
","text":" Bases: ComEditView
InfoMsgEditView
","text":" Bases: ComEditView
WeekmailDestinationEditView
","text":" Bases: ComEditView
NewsForm
","text":" Bases: ModelForm
NewsEditView
","text":" Bases: CanEditMixin
, UpdateView
NewsCreateView
","text":" Bases: CanCreateMixin
, CreateView
NewsDeleteView
","text":" Bases: CanEditMixin
, DeleteView
NewsModerateView
","text":" Bases: CanEditMixin
, SingleObjectMixin
NewsAdminListView
","text":" Bases: CanEditMixin
, ListView
NewsListView
","text":" Bases: CanViewMixin
, ListView
NewsDetailView
","text":" Bases: CanViewMixin
, DetailView
WeekmailPreviewView
","text":" Bases: ComTabsMixin
, QuickNotifMixin
, CanEditPropMixin
, DetailView
get_context_data(**kwargs)
","text":"Add rendered weekmail.
Source code incom/views.py
def get_context_data(self, **kwargs):\n \"\"\"Add rendered weekmail.\"\"\"\n kwargs = super().get_context_data(**kwargs)\n kwargs[\"weekmail_rendered\"] = self.object.render_html()\n kwargs[\"bad_recipients\"] = self.bad_recipients\n return kwargs\n
"},{"location":"reference/com/views/#com.views.WeekmailEditView","title":"WeekmailEditView
","text":" Bases: ComTabsMixin
, QuickNotifMixin
, CanEditPropMixin
, UpdateView
get_context_data(**kwargs)
","text":"Add orphan articles.
Source code incom/views.py
def get_context_data(self, **kwargs):\n \"\"\"Add orphan articles.\"\"\"\n kwargs = super().get_context_data(**kwargs)\n kwargs[\"orphans\"] = WeekmailArticle.objects.filter(weekmail=None)\n return kwargs\n
"},{"location":"reference/com/views/#com.views.WeekmailArticleEditView","title":"WeekmailArticleEditView
","text":" Bases: ComTabsMixin
, QuickNotifMixin
, CanEditPropMixin
, UpdateView
Edit an article.
"},{"location":"reference/com/views/#com.views.WeekmailArticleCreateView","title":"WeekmailArticleCreateView
","text":" Bases: QuickNotifMixin
, CreateView
Post an article.
"},{"location":"reference/com/views/#com.views.WeekmailArticleDeleteView","title":"WeekmailArticleDeleteView
","text":" Bases: CanEditPropMixin
, DeleteView
Delete an article.
"},{"location":"reference/com/views/#com.views.MailingListAdminView","title":"MailingListAdminView
","text":" Bases: ComTabsMixin
, ListView
MailingModerateView
","text":" Bases: View
PosterListBaseView
","text":" Bases: ListView
List communication posters.
"},{"location":"reference/com/views/#com.views.PosterCreateBaseView","title":"PosterCreateBaseView
","text":" Bases: CreateView
Create communication poster.
"},{"location":"reference/com/views/#com.views.PosterEditBaseView","title":"PosterEditBaseView
","text":" Bases: UpdateView
Edit communication poster.
"},{"location":"reference/com/views/#com.views.PosterDeleteBaseView","title":"PosterDeleteBaseView
","text":" Bases: DeleteView
Edit communication poster.
"},{"location":"reference/com/views/#com.views.PosterListView","title":"PosterListView
","text":" Bases: IsComAdminMixin
, ComTabsMixin
, PosterListBaseView
List communication posters.
"},{"location":"reference/com/views/#com.views.PosterCreateView","title":"PosterCreateView
","text":" Bases: IsComAdminMixin
, ComTabsMixin
, PosterCreateBaseView
Create communication poster.
"},{"location":"reference/com/views/#com.views.PosterEditView","title":"PosterEditView
","text":" Bases: IsComAdminMixin
, ComTabsMixin
, PosterEditBaseView
Edit communication poster.
"},{"location":"reference/com/views/#com.views.PosterDeleteView","title":"PosterDeleteView
","text":" Bases: IsComAdminMixin
, ComTabsMixin
, PosterDeleteBaseView
Delete communication poster.
"},{"location":"reference/com/views/#com.views.PosterModerateListView","title":"PosterModerateListView
","text":" Bases: IsComAdminMixin
, ComTabsMixin
, ListView
Moderate list communication poster.
"},{"location":"reference/com/views/#com.views.PosterModerateView","title":"PosterModerateView
","text":" Bases: IsComAdminMixin
, ComTabsMixin
, View
Moderate communication poster.
"},{"location":"reference/com/views/#com.views.ScreenListView","title":"ScreenListView
","text":" Bases: IsComAdminMixin
, ComTabsMixin
, ListView
List communication screens.
"},{"location":"reference/com/views/#com.views.ScreenSlideshowView","title":"ScreenSlideshowView
","text":" Bases: DetailView
Slideshow of actives posters.
"},{"location":"reference/com/views/#com.views.ScreenCreateView","title":"ScreenCreateView
","text":" Bases: IsComAdminMixin
, ComTabsMixin
, CreateView
Create communication screen.
"},{"location":"reference/com/views/#com.views.ScreenEditView","title":"ScreenEditView
","text":" Bases: IsComAdminMixin
, ComTabsMixin
, UpdateView
Edit communication screen.
"},{"location":"reference/com/views/#com.views.ScreenDeleteView","title":"ScreenDeleteView
","text":" Bases: IsComAdminMixin
, ComTabsMixin
, DeleteView
Delete communication screen.
"},{"location":"reference/core/api_permissions/","title":"Api permissions","text":"Permission classes to be used within ninja-extra controllers.
Some permissions are global (like IsInGroup
or IsRoot
), and some others are per-object (like CanView
or CanEdit
).
Examples:
"},{"location":"reference/core/api_permissions/#core.api_permissions--restrict-all-the-routes-of-this-controller","title":"restrict all the routes of this controller","text":""},{"location":"reference/core/api_permissions/#core.api_permissions--to-subscribed-users","title":"to subscribed users","text":"@api_controller(\"/foo\", permissions=[IsSubscriber]) class FooController(ControllerBase): @route.get(\"/bar\") def bar_get(self): # This route inherits the permissions of the controller # ...
@route.bar(\"/bar/{bar_id}\", permissions=[CanView])\ndef bar_get_one(self, bar_id: int):\n # per-object permission resolution happens\n # when calling either the `get_object_or_exception`\n # or `get_object_or_none` method.\n bar = self.get_object_or_exception(Counter, pk=bar_id)\n\n # you can also call the `check_object_permission` manually\n other_bar = Counter.objects.first()\n self.check_object_permissions(other_bar)\n\n # ...\n\n# This route is restricted to counter admins and root users\n@route.delete(\n \"/bar/{bar_id}\",\n permissions=[IsRoot | IsInGroup(settings.SITH_GROUP_COUNTER_ADMIN_ID)\n]\ndef bar_delete(self, bar_id: int):\n # ...\n
"},{"location":"reference/core/api_permissions/#core.api_permissions.CanAccessLookup","title":"CanAccessLookup = IsOldSubscriber | IsRoot | IsLoggedInCounter
module-attribute
","text":""},{"location":"reference/core/api_permissions/#core.api_permissions.IsInGroup","title":"IsInGroup(group_pk)
","text":" Bases: BasePermission
Check that the user is in the group whose primary key is given.
Source code incore/api_permissions.py
def __init__(self, group_pk: int):\n self._group_pk = group_pk\n
"},{"location":"reference/core/api_permissions/#core.api_permissions.IsRoot","title":"IsRoot
","text":" Bases: BasePermission
Check that the user is root.
"},{"location":"reference/core/api_permissions/#core.api_permissions.IsSubscriber","title":"IsSubscriber
","text":" Bases: BasePermission
Check that the user is currently subscribed.
"},{"location":"reference/core/api_permissions/#core.api_permissions.IsOldSubscriber","title":"IsOldSubscriber
","text":" Bases: BasePermission
Check that the user has at least one subscription in its history.
"},{"location":"reference/core/api_permissions/#core.api_permissions.CanView","title":"CanView
","text":" Bases: BasePermission
Check that this user has the permission to view the object of this route.
Wrap the user.can_view(obj)
method. To see an example, look at the example in the module docstring.
CanEdit
","text":" Bases: BasePermission
Check that this user has the permission to edit the object of this route.
Wrap the user.can_edit(obj)
method. To see an example, look at the example in the module docstring.
IsOwner
","text":" Bases: BasePermission
Check that this user owns the object of this route.
Wrap the user.is_owner(obj)
method. To see an example, look at the example in the module docstring.
IsLoggedInCounter
","text":" Bases: BasePermission
Check that a user is logged in a counter.
"},{"location":"reference/core/model_fields/","title":"Champs de mod\u00e8le","text":""},{"location":"reference/core/model_fields/#core.fields.ResizedImageFieldFile","title":"ResizedImageFieldFile
","text":" Bases: ImageFieldFile
get_resized_dimensions(image)
","text":"Get the dimensions of the resized image.
If the width and height are given, they are used. If only one is given, the other is calculated to keep the same ratio.
Returns:
Type Descriptiontuple[int, int]
Tuple of width and height
Source code incore/fields.py
def get_resized_dimensions(self, image: Image.Image) -> tuple[int, int]:\n \"\"\"Get the dimensions of the resized image.\n\n If the width and height are given, they are used.\n If only one is given, the other is calculated to keep the same ratio.\n\n Returns:\n Tuple of width and height\n \"\"\"\n width = self.field.width\n height = self.field.height\n if width is not None and height is not None:\n return self.field.width, self.field.height\n if width is None:\n width = int(image.width * height / image.height)\n elif height is None:\n height = int(image.height * width / image.width)\n return width, height\n
"},{"location":"reference/core/model_fields/#core.fields.ResizedImageFieldFile.get_name","title":"get_name()
","text":"Get the name of the resized image.
If the field has a force_format attribute, the extension of the file will be changed to match it. Otherwise, the name is left unchanged.
Raises:
Type DescriptionValueError
If the image format is unknown
Source code incore/fields.py
def get_name(self) -> str:\n \"\"\"Get the name of the resized image.\n\n If the field has a force_format attribute,\n the extension of the file will be changed to match it.\n Otherwise, the name is left unchanged.\n\n Raises:\n ValueError: If the image format is unknown\n \"\"\"\n if not self.field.force_format:\n return self.name\n formats = {val: key for key, val in Image.registered_extensions().items()}\n new_format = self.field.force_format\n if new_format in formats:\n extension = formats[new_format]\n else:\n raise ValueError(f\"Unknown format {new_format}\")\n return str(Path(self.file.name).with_suffix(extension))\n
"},{"location":"reference/core/model_fields/#core.fields.ResizedImageField","title":"ResizedImageField(width=None, height=None, force_format=None, **kwargs)
","text":" Bases: ImageField
A field that automatically resizes images to a given size.
This field is useful for profile pictures or product icons, for example.
The final size of the image is determined by the width and height parameters :
If the force_format parameter is given, the image will be converted to this format.
Examples:
To resize an image with a height of 100px, without changing the ratio, and a format of WEBP :
class Product(models.Model):\n icon = ResizedImageField(height=100, force_format=\"WEBP\")\n
To explicitly resize an image to 100x100px (but possibly change the ratio) :
class Product(models.Model):\n icon = ResizedImageField(width=100, height=100)\n
Raises:
Type DescriptionFieldError
If neither width nor height is given
Parameters:
Name Type Description Defaultwidth
int | None
If given, the width of the resized image
None
height
int | None
If given, the height of the resized image
None
force_format
str | None
If given, the image will be converted to this format
None
Source code in core/fields.py
def __init__(\n self,\n width: int | None = None,\n height: int | None = None,\n force_format: str | None = None,\n **kwargs,\n):\n if width is None and height is None:\n raise FieldError(\n f\"{self.__class__.__name__} requires \"\n \"width, height or both, but got neither\"\n )\n self.width = width\n self.height = height\n self.force_format = force_format\n super().__init__(**kwargs)\n
"},{"location":"reference/core/models/","title":"Models","text":""},{"location":"reference/core/models/#core.models.Club","title":"Club
","text":" Bases: Model
The Club class, made as a tree to allow nice tidy organization.
"},{"location":"reference/core/models/#core.models.Club.check_loop","title":"check_loop()
","text":"Raise a validation error when a loop is found within the parent list.
Source code inclub/models.py
def check_loop(self):\n \"\"\"Raise a validation error when a loop is found within the parent list.\"\"\"\n objs = []\n cur = self\n while cur.parent is not None:\n if cur in objs:\n raise ValidationError(_(\"You can not make loops in clubs\"))\n objs.append(cur)\n cur = cur.parent\n
"},{"location":"reference/core/models/#core.models.Club.is_owned_by","title":"is_owned_by(user)
","text":"Method to see if that object can be super edited by the given user.
Source code inclub/models.py
def is_owned_by(self, user):\n \"\"\"Method to see if that object can be super edited by the given user.\"\"\"\n if user.is_anonymous:\n return False\n return user.is_board_member\n
"},{"location":"reference/core/models/#core.models.Club.can_be_edited_by","title":"can_be_edited_by(user)
","text":"Method to see if that object can be edited by the given user.
Source code inclub/models.py
def can_be_edited_by(self, user):\n \"\"\"Method to see if that object can be edited by the given user.\"\"\"\n return self.has_rights_in_club(user)\n
"},{"location":"reference/core/models/#core.models.Club.can_be_viewed_by","title":"can_be_viewed_by(user)
","text":"Method to see if that object can be seen by the given user.
Source code inclub/models.py
def can_be_viewed_by(self, user):\n \"\"\"Method to see if that object can be seen by the given user.\"\"\"\n sub = User.objects.filter(pk=user.pk).first()\n if sub is None:\n return False\n return sub.was_subscribed\n
"},{"location":"reference/core/models/#core.models.Club.get_membership_for","title":"get_membership_for(user)
","text":"Return the current membership the given user.
NoteThe result is cached.
Source code inclub/models.py
def get_membership_for(self, user: User) -> Membership | None:\n \"\"\"Return the current membership the given user.\n\n Note:\n The result is cached.\n \"\"\"\n if user.is_anonymous:\n return None\n membership = cache.get(f\"membership_{self.id}_{user.id}\")\n if membership == \"not_member\":\n return None\n if membership is None:\n membership = self.members.filter(user=user, end_date=None).first()\n if membership is None:\n cache.set(f\"membership_{self.id}_{user.id}\", \"not_member\")\n else:\n cache.set(f\"membership_{self.id}_{user.id}\", membership)\n return membership\n
"},{"location":"reference/core/models/#core.models.RealGroupManager","title":"RealGroupManager
","text":" Bases: GroupManager
MetaGroupManager
","text":" Bases: GroupManager
Group
","text":" Bases: Group
Implement both RealGroups and Meta groups.
Groups are sorted by their is_meta property
"},{"location":"reference/core/models/#core.models.MetaGroup","title":"MetaGroup(*args, **kwargs)
","text":" Bases: Group
MetaGroups are dynamically created groups.
Generally used with clubs where creating a club creates two groups:
core/models.py
def __init__(self, *args, **kwargs):\n super().__init__(*args, **kwargs)\n self.is_meta = True\n
"},{"location":"reference/core/models/#core.models.MetaGroup.associated_club","title":"associated_club()
","text":"Return the group associated with this meta group.
The result of this function is cached
Returns:
Type DescriptionClub | None
The associated club if it exists, else None
Source code incore/models.py
@cached_property\ndef associated_club(self) -> Club | None:\n \"\"\"Return the group associated with this meta group.\n\n The result of this function is cached\n\n\n Returns:\n The associated club if it exists, else None\n \"\"\"\n from club.models import Club\n\n if self.name.endswith(settings.SITH_BOARD_SUFFIX):\n # replace this with str.removesuffix as soon as Python\n # is upgraded to 3.10\n club_name = self.name[: -len(settings.SITH_BOARD_SUFFIX)]\n elif self.name.endswith(settings.SITH_MEMBER_SUFFIX):\n club_name = self.name[: -len(settings.SITH_MEMBER_SUFFIX)]\n else:\n return None\n club = cache.get(f\"sith_club_{club_name}\")\n if club is None:\n club = Club.objects.filter(unix_name=club_name).first()\n cache.set(f\"sith_club_{club_name}\", club)\n return club\n
"},{"location":"reference/core/models/#core.models.RealGroup","title":"RealGroup
","text":" Bases: Group
RealGroups are created by the developer.
Most of the time they match a number in settings to be easily used for permissions.
"},{"location":"reference/core/models/#core.models.UserQuerySet","title":"UserQuerySet
","text":" Bases: QuerySet
CustomUserManager
","text":" Bases: from_queryset(UserQuerySet)
User
","text":" Bases: AbstractBaseUser
Defines the base user class, useable in every app.
This is almost the same as the auth module AbstractUser since it inherits from it, but some fields are required, and the username is generated automatically with the name of the user (see generate_username()).
Added field: nick_name, date_of_birth Required fields: email, first_name, last_name, date_of_birth
"},{"location":"reference/core/models/#core.models.User.cached_groups","title":"cached_groups: list[Group]
property
","text":"Get the list of groups this user is in.
The result is cached for the default duration (should be 5 minutes)
Returns: A list of all the groups this user is in.
"},{"location":"reference/core/models/#core.models.User.is_in_group","title":"is_in_group(*, pk=None, name=None)
","text":"Check if this user is in the given group. Either a group id or a group name must be provided. If both are passed, only the id will be considered.
The group will be fetched using the given parameter. If no group is found, return False. If a group is found, check if this user is in the latter.
Returns:
Type Descriptionbool
True if the user is the group, else False
Source code incore/models.py
def is_in_group(self, *, pk: int | None = None, name: str | None = None) -> bool:\n \"\"\"Check if this user is in the given group.\n Either a group id or a group name must be provided.\n If both are passed, only the id will be considered.\n\n The group will be fetched using the given parameter.\n If no group is found, return False.\n If a group is found, check if this user is in the latter.\n\n Returns:\n True if the user is the group, else False\n \"\"\"\n if pk is not None:\n group: Optional[Group] = get_group(pk=pk)\n elif name is not None:\n group: Optional[Group] = get_group(name=name)\n else:\n raise ValueError(\"You must either provide the id or the name of the group\")\n if group is None:\n return False\n if group.id == settings.SITH_GROUP_PUBLIC_ID:\n return True\n if group.id == settings.SITH_GROUP_SUBSCRIBERS_ID:\n return self.is_subscribed\n if group.id == settings.SITH_GROUP_OLD_SUBSCRIBERS_ID:\n return self.was_subscribed\n if group.id == settings.SITH_GROUP_ROOT_ID:\n return self.is_root\n if group.is_meta:\n # check if this group is associated with a club\n group.__class__ = MetaGroup\n club = group.associated_club\n if club is None:\n return False\n membership = club.get_membership_for(self)\n if membership is None:\n return False\n if group.name.endswith(settings.SITH_MEMBER_SUFFIX):\n return True\n return membership.role > settings.SITH_MAXIMUM_FREE_ROLE\n return group in self.cached_groups\n
"},{"location":"reference/core/models/#core.models.User.age","title":"age()
","text":"Return the age this user has the day the method is called. If the user has not filled his age, return 0.
Source code incore/models.py
@cached_property\ndef age(self) -> int:\n \"\"\"Return the age this user has the day the method is called.\n If the user has not filled his age, return 0.\n \"\"\"\n if self.date_of_birth is None:\n return 0\n today = timezone.now()\n age = today.year - self.date_of_birth.year\n # remove a year if this year's birthday is yet to come\n age -= (today.month, today.day) < (\n self.date_of_birth.month,\n self.date_of_birth.day,\n )\n return age\n
"},{"location":"reference/core/models/#core.models.User.get_full_name","title":"get_full_name()
","text":"Returns the first_name plus the last_name, with a space in between.
Source code incore/models.py
def get_full_name(self):\n \"\"\"Returns the first_name plus the last_name, with a space in between.\"\"\"\n full_name = \"%s %s\" % (self.first_name, self.last_name)\n return full_name.strip()\n
"},{"location":"reference/core/models/#core.models.User.get_short_name","title":"get_short_name()
","text":"Returns the short name for the user.
Source code incore/models.py
def get_short_name(self):\n \"\"\"Returns the short name for the user.\"\"\"\n if self.nick_name:\n return self.nick_name\n return self.first_name + \" \" + self.last_name\n
"},{"location":"reference/core/models/#core.models.User.get_display_name","title":"get_display_name()
","text":"Returns the display name of the user.
A nickname if possible, otherwise, the full name.
Source code incore/models.py
def get_display_name(self) -> str:\n \"\"\"Returns the display name of the user.\n\n A nickname if possible, otherwise, the full name.\n \"\"\"\n if self.nick_name:\n return \"%s (%s)\" % (self.get_full_name(), self.nick_name)\n return self.get_full_name()\n
"},{"location":"reference/core/models/#core.models.User.get_age","title":"get_age()
","text":"Returns the age.
Source code incore/models.py
def get_age(self):\n \"\"\"Returns the age.\"\"\"\n today = timezone.now()\n born = self.date_of_birth\n return (\n today.year - born.year - ((today.month, today.day) < (born.month, born.day))\n )\n
"},{"location":"reference/core/models/#core.models.User.get_family","title":"get_family(godfathers_depth=4, godchildren_depth=4)
","text":"Get the family of the user, with the given depth.
Parameters:
Name Type Description Defaultgodfathers_depth
NonNegativeInt
The number of generations of godfathers to fetch
4
godchildren_depth
NonNegativeInt
The number of generations of godchildren to fetch
4
Returns:
Type Descriptionset[through]
A list of family relationships in this user's family
Source code incore/models.py
def get_family(\n self,\n godfathers_depth: NonNegativeInt = 4,\n godchildren_depth: NonNegativeInt = 4,\n) -> set[User.godfathers.through]:\n \"\"\"Get the family of the user, with the given depth.\n\n Args:\n godfathers_depth: The number of generations of godfathers to fetch\n godchildren_depth: The number of generations of godchildren to fetch\n\n Returns:\n A list of family relationships in this user's family\n \"\"\"\n res = []\n for depth, key, reverse_key in [\n (godfathers_depth, \"from_user_id\", \"to_user_id\"),\n (godchildren_depth, \"to_user_id\", \"from_user_id\"),\n ]:\n if depth == 0:\n continue\n links = list(User.godfathers.through.objects.filter(**{key: self.id}))\n res.extend(links)\n for _ in range(1, depth): # noqa: F402 we don't care about gettext here\n ids = [getattr(c, reverse_key) for c in links]\n links = list(\n User.godfathers.through.objects.filter(\n **{f\"{key}__in\": ids}\n ).exclude(id__in=[r.id for r in res])\n )\n if not links:\n break\n res.extend(links)\n return set(res)\n
"},{"location":"reference/core/models/#core.models.User.email_user","title":"email_user(subject, message, from_email=None, **kwargs)
","text":"Sends an email to this User.
Source code incore/models.py
def email_user(self, subject, message, from_email=None, **kwargs):\n \"\"\"Sends an email to this User.\"\"\"\n if from_email is None:\n from_email = settings.DEFAULT_FROM_EMAIL\n send_mail(subject, message, from_email, [self.email], **kwargs)\n
"},{"location":"reference/core/models/#core.models.User.generate_username","title":"generate_username()
","text":"Generates a unique username based on the first and last names.
For example: Guy Carlier gives gcarlier, and gcarlier1 if the first one exists.
Returns:
Type Descriptionstr
The generated username.
Source code incore/models.py
def generate_username(self) -> str:\n \"\"\"Generates a unique username based on the first and last names.\n\n For example: Guy Carlier gives gcarlier, and gcarlier1 if the first one exists.\n\n Returns:\n The generated username.\n \"\"\"\n\n def remove_accents(data):\n return \"\".join(\n x\n for x in unicodedata.normalize(\"NFKD\", data)\n if unicodedata.category(x)[0] == \"L\"\n ).lower()\n\n user_name = (\n remove_accents(self.first_name[0] + self.last_name)\n .encode(\"ascii\", \"ignore\")\n .decode(\"utf-8\")\n )\n un_set = [u.username for u in User.objects.all()]\n if user_name in un_set:\n i = 1\n while user_name + str(i) in un_set:\n i += 1\n user_name += str(i)\n self.username = user_name\n return user_name\n
"},{"location":"reference/core/models/#core.models.User.is_owner","title":"is_owner(obj)
","text":"Determine if the object is owned by the user.
Source code incore/models.py
def is_owner(self, obj):\n \"\"\"Determine if the object is owned by the user.\"\"\"\n if hasattr(obj, \"is_owned_by\") and obj.is_owned_by(self):\n return True\n if hasattr(obj, \"owner_group\") and self.is_in_group(pk=obj.owner_group.id):\n return True\n return self.is_root\n
"},{"location":"reference/core/models/#core.models.User.can_edit","title":"can_edit(obj)
","text":"Determine if the object can be edited by the user.
Source code incore/models.py
def can_edit(self, obj):\n \"\"\"Determine if the object can be edited by the user.\"\"\"\n if hasattr(obj, \"can_be_edited_by\") and obj.can_be_edited_by(self):\n return True\n if hasattr(obj, \"edit_groups\"):\n for pk in obj.edit_groups.values_list(\"pk\", flat=True):\n if self.is_in_group(pk=pk):\n return True\n if isinstance(obj, User) and obj == self:\n return True\n return self.is_owner(obj)\n
"},{"location":"reference/core/models/#core.models.User.can_view","title":"can_view(obj)
","text":"Determine if the object can be viewed by the user.
Source code incore/models.py
def can_view(self, obj):\n \"\"\"Determine if the object can be viewed by the user.\"\"\"\n if hasattr(obj, \"can_be_viewed_by\") and obj.can_be_viewed_by(self):\n return True\n if hasattr(obj, \"view_groups\"):\n for pk in obj.view_groups.values_list(\"pk\", flat=True):\n if self.is_in_group(pk=pk):\n return True\n return self.can_edit(obj)\n
"},{"location":"reference/core/models/#core.models.User.clubs_with_rights","title":"clubs_with_rights()
","text":"The list of clubs where the user has rights
Source code incore/models.py
@cached_property\ndef clubs_with_rights(self) -> list[Club]:\n \"\"\"The list of clubs where the user has rights\"\"\"\n memberships = self.memberships.ongoing().board().select_related(\"club\")\n return [m.club for m in memberships]\n
"},{"location":"reference/core/models/#core.models.AnonymousUser","title":"AnonymousUser()
","text":" Bases: AnonymousUser
core/models.py
def __init__(self):\n super().__init__()\n
"},{"location":"reference/core/models/#core.models.AnonymousUser.is_in_group","title":"is_in_group(*, pk=None, name=None)
","text":"The anonymous user is only in the public group.
Source code incore/models.py
def is_in_group(self, *, pk: int | None = None, name: str | None = None) -> bool:\n \"\"\"The anonymous user is only in the public group.\"\"\"\n allowed_id = settings.SITH_GROUP_PUBLIC_ID\n if pk is not None:\n return pk == allowed_id\n elif name is not None:\n group = get_group(name=name)\n return group is not None and group.id == allowed_id\n else:\n raise ValueError(\"You must either provide the id or the name of the group\")\n
"},{"location":"reference/core/models/#core.models.Preferences","title":"Preferences
","text":" Bases: Model
SithFile
","text":" Bases: Model
clean()
","text":"Cleans up the file.
Source code incore/models.py
def clean(self):\n \"\"\"Cleans up the file.\"\"\"\n super().clean()\n if \"/\" in self.name:\n raise ValidationError(_(\"Character '/' not authorized in name\"))\n if self == self.parent:\n raise ValidationError(_(\"Loop in folder tree\"), code=\"loop\")\n if self == self.parent or (\n self.parent is not None and self in self.get_parent_list()\n ):\n raise ValidationError(_(\"Loop in folder tree\"), code=\"loop\")\n if self.parent and self.parent.is_file:\n raise ValidationError(\n _(\"You can not make a file be a children of a non folder file\")\n )\n if (\n self.parent is None\n and SithFile.objects.exclude(id=self.id)\n .filter(parent=None, name=self.name)\n .exists()\n ) or (\n self.parent\n and self.parent.children.exclude(id=self.id).filter(name=self.name).exists()\n ):\n raise ValidationError(_(\"Duplicate file\"), code=\"duplicate\")\n if self.is_folder:\n if self.file:\n try:\n import imghdr\n\n if imghdr.what(None, self.file.read()) not in [\n \"gif\",\n \"png\",\n \"jpeg\",\n ]:\n self.file.delete()\n self.file = None\n except: # noqa E722 I don't know the exception that can be raised\n self.file = None\n self.mime_type = \"inode/directory\"\n if self.is_file and (self.file is None or self.file == \"\"):\n raise ValidationError(_(\"You must provide a file\"))\n
"},{"location":"reference/core/models/#core.models.SithFile.apply_rights_recursively","title":"apply_rights_recursively(*, only_folders=False)
","text":"Apply the rights of this file to all children recursively.
Parameters:
Name Type Description Defaultonly_folders
bool
If True, only apply the rights to SithFiles that are folders.
False
Source code in core/models.py
def apply_rights_recursively(self, *, only_folders: bool = False) -> None:\n \"\"\"Apply the rights of this file to all children recursively.\n\n Args:\n only_folders: If True, only apply the rights to SithFiles that are folders.\n \"\"\"\n file_ids = []\n explored_ids = [self.id]\n while len(explored_ids) > 0: # find all children recursively\n file_ids.extend(explored_ids)\n next_level = SithFile.objects.filter(parent_id__in=explored_ids)\n if only_folders:\n next_level = next_level.filter(is_folder=True)\n explored_ids = list(next_level.values_list(\"id\", flat=True))\n for through in (SithFile.view_groups.through, SithFile.edit_groups.through):\n # force evaluation. Without this, the iterator yields nothing\n groups = list(\n through.objects.filter(sithfile_id=self.id).values_list(\n \"group_id\", flat=True\n )\n )\n # delete previous rights\n through.objects.filter(sithfile_id__in=file_ids).delete()\n through.objects.bulk_create( # create new rights\n [through(sithfile_id=f, group_id=g) for f in file_ids for g in groups]\n )\n
"},{"location":"reference/core/models/#core.models.SithFile.copy_rights","title":"copy_rights()
","text":"Copy, if possible, the rights of the parent folder.
Source code incore/models.py
def copy_rights(self):\n \"\"\"Copy, if possible, the rights of the parent folder.\"\"\"\n if self.parent is not None:\n self.edit_groups.set(self.parent.edit_groups.all())\n self.view_groups.set(self.parent.view_groups.all())\n
"},{"location":"reference/core/models/#core.models.SithFile.move_to","title":"move_to(parent)
","text":"Move a file to a new parent. parent
must be a SithFile with the is_folder=True
property. Otherwise, this function doesn't change anything. This is done only at the DB level, so that it's very fast for the user. Indeed, this function doesn't modify SithFiles recursively, so it stays efficient even with top-level folders.
core/models.py
def move_to(self, parent):\n \"\"\"Move a file to a new parent.\n `parent` must be a SithFile with the `is_folder=True` property. Otherwise, this function doesn't change\n anything.\n This is done only at the DB level, so that it's very fast for the user. Indeed, this function doesn't modify\n SithFiles recursively, so it stays efficient even with top-level folders.\n \"\"\"\n if not parent.is_folder:\n return\n self.parent = parent\n self.clean()\n self.save()\n
"},{"location":"reference/core/models/#core.models.LockError","title":"LockError
","text":" Bases: Exception
There was a lock error on the object.
"},{"location":"reference/core/models/#core.models.AlreadyLocked","title":"AlreadyLocked
","text":" Bases: LockError
The object is already locked.
"},{"location":"reference/core/models/#core.models.NotLocked","title":"NotLocked
","text":" Bases: LockError
The object is not locked.
"},{"location":"reference/core/models/#core.models.Page","title":"Page
","text":" Bases: Model
The page class to build a Wiki Each page may have a parent and it's URL is of the form my.site/page/// It has an ID field, but don't use it, since it's only there for DB part, and because compound primary key is awkward! Prefere querying pages with Page.get_page_by_full_name().
Be careful with the _full_name attribute: this field may not be valid until you call save(). It's made for fast query, but don't rely on it when playing with a Page object, use get_full_name() instead!
"},{"location":"reference/core/models/#core.models.Page.save","title":"save(*args, **kwargs)
","text":"Performs some needed actions before and after saving a page in database.
Source code incore/models.py
def save(self, *args, **kwargs):\n \"\"\"Performs some needed actions before and after saving a page in database.\"\"\"\n locked = kwargs.pop(\"force_lock\", False)\n if not locked:\n locked = self.is_locked()\n if not locked:\n raise NotLocked(\"The page is not locked and thus can not be saved\")\n self.full_clean()\n if not self.id:\n super().save(\n *args, **kwargs\n ) # Save a first time to correctly set _full_name\n # This reset the _full_name just before saving to maintain a coherent field quicker for queries than the\n # recursive method\n # It also update all the children to maintain correct names\n self._full_name = self.get_full_name()\n for c in self.children.all():\n c.save()\n super().save(*args, **kwargs)\n self.unset_lock()\n
"},{"location":"reference/core/models/#core.models.Page.get_page_by_full_name","title":"get_page_by_full_name(name)
staticmethod
","text":"Quicker to get a page with that method rather than building the request every time.
Source code incore/models.py
@staticmethod\ndef get_page_by_full_name(name):\n \"\"\"Quicker to get a page with that method rather than building the request every time.\"\"\"\n return Page.objects.filter(_full_name=name).first()\n
"},{"location":"reference/core/models/#core.models.Page.clean","title":"clean()
","text":"Cleans up only the name for the moment, but this can be used to make any treatment before saving the object.
Source code incore/models.py
def clean(self):\n \"\"\"Cleans up only the name for the moment, but this can be used to make any treatment before saving the object.\"\"\"\n if \"/\" in self.name:\n self.name = self.name.split(\"/\")[-1]\n if (\n Page.objects.exclude(pk=self.pk)\n .filter(_full_name=self.get_full_name())\n .exists()\n ):\n raise ValidationError(_(\"Duplicate page\"), code=\"duplicate\")\n super().clean()\n if self.parent is not None and self in self.get_parent_list():\n raise ValidationError(_(\"Loop in page tree\"), code=\"loop\")\n
"},{"location":"reference/core/models/#core.models.Page.is_locked","title":"is_locked()
","text":"Is True if the page is locked, False otherwise.
This is where the timeout is handled, so a locked page for which the timeout is reach will be unlocked and this function will return False.
Source code incore/models.py
def is_locked(self):\n \"\"\"Is True if the page is locked, False otherwise.\n\n This is where the timeout is handled,\n so a locked page for which the timeout is reach will be unlocked and this\n function will return False.\n \"\"\"\n if self.lock_timeout and (\n timezone.now() - self.lock_timeout > timedelta(minutes=5)\n ):\n self.unset_lock()\n return (\n self.lock_user\n and self.lock_timeout\n and (timezone.now() - self.lock_timeout < timedelta(minutes=5))\n )\n
"},{"location":"reference/core/models/#core.models.Page.set_lock","title":"set_lock(user)
","text":"Sets a lock on the current page or raise an AlreadyLocked exception.
Source code incore/models.py
def set_lock(self, user):\n \"\"\"Sets a lock on the current page or raise an AlreadyLocked exception.\"\"\"\n if self.is_locked() and self.get_lock() != user:\n raise AlreadyLocked(\"The page is already locked by someone else\")\n self.lock_user = user\n self.lock_timeout = timezone.now()\n super().save()\n
"},{"location":"reference/core/models/#core.models.Page.set_lock_recursive","title":"set_lock_recursive(user)
","text":"Locks recursively all the child pages for editing properties.
Source code incore/models.py
def set_lock_recursive(self, user):\n \"\"\"Locks recursively all the child pages for editing properties.\"\"\"\n for p in self.children.all():\n p.set_lock_recursive(user)\n self.set_lock(user)\n
"},{"location":"reference/core/models/#core.models.Page.unset_lock_recursive","title":"unset_lock_recursive()
","text":"Unlocks recursively all the child pages.
Source code incore/models.py
def unset_lock_recursive(self):\n \"\"\"Unlocks recursively all the child pages.\"\"\"\n for p in self.children.all():\n p.unset_lock_recursive()\n self.unset_lock()\n
"},{"location":"reference/core/models/#core.models.Page.unset_lock","title":"unset_lock()
","text":"Always try to unlock, even if there is no lock.
Source code incore/models.py
def unset_lock(self):\n \"\"\"Always try to unlock, even if there is no lock.\"\"\"\n self.lock_user = None\n self.lock_timeout = None\n super().save()\n
"},{"location":"reference/core/models/#core.models.Page.get_lock","title":"get_lock()
","text":"Returns the page's mutex containing the time and the user in a dict.
Source code incore/models.py
def get_lock(self):\n \"\"\"Returns the page's mutex containing the time and the user in a dict.\"\"\"\n if self.lock_user:\n return self.lock_user\n raise NotLocked(\"The page is not locked and thus can not return its user\")\n
"},{"location":"reference/core/models/#core.models.Page.get_full_name","title":"get_full_name()
","text":"Computes the real full_name of the page based on its name and its parent's name You can and must rely on this function when working on a page object that is not freshly fetched from the DB (For example when treating a Page object coming from a form).
Source code incore/models.py
def get_full_name(self):\n \"\"\"Computes the real full_name of the page based on its name and its parent's name\n You can and must rely on this function when working on a page object that is not freshly fetched from the DB\n (For example when treating a Page object coming from a form).\n \"\"\"\n if self.parent is None:\n return self.name\n return f\"{self.parent.get_full_name()}/{self.name}\"\n
"},{"location":"reference/core/models/#core.models.PageRev","title":"PageRev
","text":" Bases: Model
True content of the page.
Each page object has a revisions field that is a list of PageRev, ordered by date. my_page.revisions.last() gives the PageRev object that is the most up-to-date, and thus, is the real content of the page. The content is in PageRev.title and PageRev.content .
"},{"location":"reference/core/models/#core.models.Notification","title":"Notification
","text":" Bases: Model
Gift
","text":" Bases: Model
OperationLog
","text":" Bases: Model
General purpose log object to register operations.
"},{"location":"reference/core/models/#core.models.validate_promo","title":"validate_promo(value)
","text":"Source code in core/models.py
def validate_promo(value: int) -> None:\n start_year = settings.SITH_SCHOOL_START_YEAR\n delta = (localdate() + timedelta(days=180)).year - start_year\n if value < 0 or delta < value:\n raise ValidationError(\n _(\"%(value)s is not a valid promo (between 0 and %(end)s)\"),\n params={\"value\": value, \"end\": delta},\n )\n
"},{"location":"reference/core/models/#core.models.get_group","title":"get_group(*, pk=None, name=None)
","text":"Search for a group by its primary key or its name. Either one of the two must be set.
The result is cached for the default duration (should be 5 minutes).
Parameters:
Name Type Description Defaultpk
int | None
The primary key of the group
None
name
str | None
The name of the group
None
Returns:
Type DescriptionGroup | None
The group if it exists, else None
Raises:
Type DescriptionValueError
If no group matches the criteria
Source code incore/models.py
def get_group(*, pk: int | None = None, name: str | None = None) -> Group | None:\n \"\"\"Search for a group by its primary key or its name.\n Either one of the two must be set.\n\n The result is cached for the default duration (should be 5 minutes).\n\n Args:\n pk: The primary key of the group\n name: The name of the group\n\n Returns:\n The group if it exists, else None\n\n Raises:\n ValueError: If no group matches the criteria\n \"\"\"\n if pk is None and name is None:\n raise ValueError(\"Either pk or name must be set\")\n\n # replace space characters to hide warnings with memcached backend\n pk_or_name: str | int = pk if pk is not None else name.replace(\" \", \"_\")\n group = cache.get(f\"sith_group_{pk_or_name}\")\n\n if group == \"not_found\":\n # Using None as a cache value is a little bit tricky,\n # so we use a special string to represent None\n return None\n elif group is not None:\n return group\n # if this point is reached, the group is not in cache\n if pk is not None:\n group = Group.objects.filter(pk=pk).first()\n else:\n group = Group.objects.filter(name=name).first()\n if group is not None:\n cache.set(f\"sith_group_{group.id}\", group)\n cache.set(f\"sith_group_{group.name.replace(' ', '_')}\", group)\n else:\n cache.set(f\"sith_group_{pk_or_name}\", \"not_found\")\n return group\n
"},{"location":"reference/core/models/#core.models.get_directory","title":"get_directory(instance, filename)
","text":"Source code in core/models.py
def get_directory(instance, filename):\n return \".{0}/{1}\".format(instance.get_parent_path(), filename)\n
"},{"location":"reference/core/models/#core.models.get_compressed_directory","title":"get_compressed_directory(instance, filename)
","text":"Source code in core/models.py
def get_compressed_directory(instance, filename):\n return \"./.compressed/{0}/{1}\".format(instance.get_parent_path(), filename)\n
"},{"location":"reference/core/models/#core.models.get_thumbnail_directory","title":"get_thumbnail_directory(instance, filename)
","text":"Source code in core/models.py
def get_thumbnail_directory(instance, filename):\n return \"./.thumbnails/{0}/{1}\".format(instance.get_parent_path(), filename)\n
"},{"location":"reference/core/models/#core.models.get_default_owner_group","title":"get_default_owner_group()
","text":"Source code in core/models.py
def get_default_owner_group():\n return settings.SITH_GROUP_ROOT_ID\n
"},{"location":"reference/core/schemas/","title":"Schemas","text":""},{"location":"reference/core/schemas/#core.schemas.Group","title":"Group
","text":" Bases: Group
Implement both RealGroups and Meta groups.
Groups are sorted by their is_meta property
"},{"location":"reference/core/schemas/#core.schemas.SithFile","title":"SithFile
","text":" Bases: Model
clean()
","text":"Cleans up the file.
Source code incore/models.py
def clean(self):\n \"\"\"Cleans up the file.\"\"\"\n super().clean()\n if \"/\" in self.name:\n raise ValidationError(_(\"Character '/' not authorized in name\"))\n if self == self.parent:\n raise ValidationError(_(\"Loop in folder tree\"), code=\"loop\")\n if self == self.parent or (\n self.parent is not None and self in self.get_parent_list()\n ):\n raise ValidationError(_(\"Loop in folder tree\"), code=\"loop\")\n if self.parent and self.parent.is_file:\n raise ValidationError(\n _(\"You can not make a file be a children of a non folder file\")\n )\n if (\n self.parent is None\n and SithFile.objects.exclude(id=self.id)\n .filter(parent=None, name=self.name)\n .exists()\n ) or (\n self.parent\n and self.parent.children.exclude(id=self.id).filter(name=self.name).exists()\n ):\n raise ValidationError(_(\"Duplicate file\"), code=\"duplicate\")\n if self.is_folder:\n if self.file:\n try:\n import imghdr\n\n if imghdr.what(None, self.file.read()) not in [\n \"gif\",\n \"png\",\n \"jpeg\",\n ]:\n self.file.delete()\n self.file = None\n except: # noqa E722 I don't know the exception that can be raised\n self.file = None\n self.mime_type = \"inode/directory\"\n if self.is_file and (self.file is None or self.file == \"\"):\n raise ValidationError(_(\"You must provide a file\"))\n
"},{"location":"reference/core/schemas/#core.schemas.SithFile.apply_rights_recursively","title":"apply_rights_recursively(*, only_folders=False)
","text":"Apply the rights of this file to all children recursively.
Parameters:
Name Type Description Defaultonly_folders
bool
If True, only apply the rights to SithFiles that are folders.
False
Source code in core/models.py
def apply_rights_recursively(self, *, only_folders: bool = False) -> None:\n \"\"\"Apply the rights of this file to all children recursively.\n\n Args:\n only_folders: If True, only apply the rights to SithFiles that are folders.\n \"\"\"\n file_ids = []\n explored_ids = [self.id]\n while len(explored_ids) > 0: # find all children recursively\n file_ids.extend(explored_ids)\n next_level = SithFile.objects.filter(parent_id__in=explored_ids)\n if only_folders:\n next_level = next_level.filter(is_folder=True)\n explored_ids = list(next_level.values_list(\"id\", flat=True))\n for through in (SithFile.view_groups.through, SithFile.edit_groups.through):\n # force evaluation. Without this, the iterator yields nothing\n groups = list(\n through.objects.filter(sithfile_id=self.id).values_list(\n \"group_id\", flat=True\n )\n )\n # delete previous rights\n through.objects.filter(sithfile_id__in=file_ids).delete()\n through.objects.bulk_create( # create new rights\n [through(sithfile_id=f, group_id=g) for f in file_ids for g in groups]\n )\n
"},{"location":"reference/core/schemas/#core.schemas.SithFile.copy_rights","title":"copy_rights()
","text":"Copy, if possible, the rights of the parent folder.
Source code incore/models.py
def copy_rights(self):\n \"\"\"Copy, if possible, the rights of the parent folder.\"\"\"\n if self.parent is not None:\n self.edit_groups.set(self.parent.edit_groups.all())\n self.view_groups.set(self.parent.view_groups.all())\n
"},{"location":"reference/core/schemas/#core.schemas.SithFile.move_to","title":"move_to(parent)
","text":"Move a file to a new parent. parent
must be a SithFile with the is_folder=True
property. Otherwise, this function doesn't change anything. This is done only at the DB level, so that it's very fast for the user. Indeed, this function doesn't modify SithFiles recursively, so it stays efficient even with top-level folders.
core/models.py
def move_to(self, parent):\n \"\"\"Move a file to a new parent.\n `parent` must be a SithFile with the `is_folder=True` property. Otherwise, this function doesn't change\n anything.\n This is done only at the DB level, so that it's very fast for the user. Indeed, this function doesn't modify\n SithFiles recursively, so it stays efficient even with top-level folders.\n \"\"\"\n if not parent.is_folder:\n return\n self.parent = parent\n self.clean()\n self.save()\n
"},{"location":"reference/core/schemas/#core.schemas.User","title":"User
","text":" Bases: AbstractBaseUser
Defines the base user class, useable in every app.
This is almost the same as the auth module AbstractUser since it inherits from it, but some fields are required, and the username is generated automatically with the name of the user (see generate_username()).
Added field: nick_name, date_of_birth Required fields: email, first_name, last_name, date_of_birth
"},{"location":"reference/core/schemas/#core.schemas.User.cached_groups","title":"cached_groups: list[Group]
property
","text":"Get the list of groups this user is in.
The result is cached for the default duration (should be 5 minutes)
Returns: A list of all the groups this user is in.
"},{"location":"reference/core/schemas/#core.schemas.User.is_in_group","title":"is_in_group(*, pk=None, name=None)
","text":"Check if this user is in the given group. Either a group id or a group name must be provided. If both are passed, only the id will be considered.
The group will be fetched using the given parameter. If no group is found, return False. If a group is found, check if this user is in the latter.
Returns:
Type Descriptionbool
True if the user is the group, else False
Source code incore/models.py
def is_in_group(self, *, pk: int | None = None, name: str | None = None) -> bool:\n \"\"\"Check if this user is in the given group.\n Either a group id or a group name must be provided.\n If both are passed, only the id will be considered.\n\n The group will be fetched using the given parameter.\n If no group is found, return False.\n If a group is found, check if this user is in the latter.\n\n Returns:\n True if the user is the group, else False\n \"\"\"\n if pk is not None:\n group: Optional[Group] = get_group(pk=pk)\n elif name is not None:\n group: Optional[Group] = get_group(name=name)\n else:\n raise ValueError(\"You must either provide the id or the name of the group\")\n if group is None:\n return False\n if group.id == settings.SITH_GROUP_PUBLIC_ID:\n return True\n if group.id == settings.SITH_GROUP_SUBSCRIBERS_ID:\n return self.is_subscribed\n if group.id == settings.SITH_GROUP_OLD_SUBSCRIBERS_ID:\n return self.was_subscribed\n if group.id == settings.SITH_GROUP_ROOT_ID:\n return self.is_root\n if group.is_meta:\n # check if this group is associated with a club\n group.__class__ = MetaGroup\n club = group.associated_club\n if club is None:\n return False\n membership = club.get_membership_for(self)\n if membership is None:\n return False\n if group.name.endswith(settings.SITH_MEMBER_SUFFIX):\n return True\n return membership.role > settings.SITH_MAXIMUM_FREE_ROLE\n return group in self.cached_groups\n
"},{"location":"reference/core/schemas/#core.schemas.User.age","title":"age()
","text":"Return the age this user has the day the method is called. If the user has not filled his age, return 0.
Source code incore/models.py
@cached_property\ndef age(self) -> int:\n \"\"\"Return the age this user has the day the method is called.\n If the user has not filled his age, return 0.\n \"\"\"\n if self.date_of_birth is None:\n return 0\n today = timezone.now()\n age = today.year - self.date_of_birth.year\n # remove a year if this year's birthday is yet to come\n age -= (today.month, today.day) < (\n self.date_of_birth.month,\n self.date_of_birth.day,\n )\n return age\n
"},{"location":"reference/core/schemas/#core.schemas.User.get_full_name","title":"get_full_name()
","text":"Returns the first_name plus the last_name, with a space in between.
Source code incore/models.py
def get_full_name(self):\n \"\"\"Returns the first_name plus the last_name, with a space in between.\"\"\"\n full_name = \"%s %s\" % (self.first_name, self.last_name)\n return full_name.strip()\n
"},{"location":"reference/core/schemas/#core.schemas.User.get_short_name","title":"get_short_name()
","text":"Returns the short name for the user.
Source code incore/models.py
def get_short_name(self):\n \"\"\"Returns the short name for the user.\"\"\"\n if self.nick_name:\n return self.nick_name\n return self.first_name + \" \" + self.last_name\n
"},{"location":"reference/core/schemas/#core.schemas.User.get_display_name","title":"get_display_name()
","text":"Returns the display name of the user.
A nickname if possible, otherwise, the full name.
Source code incore/models.py
def get_display_name(self) -> str:\n \"\"\"Returns the display name of the user.\n\n A nickname if possible, otherwise, the full name.\n \"\"\"\n if self.nick_name:\n return \"%s (%s)\" % (self.get_full_name(), self.nick_name)\n return self.get_full_name()\n
"},{"location":"reference/core/schemas/#core.schemas.User.get_age","title":"get_age()
","text":"Returns the age.
Source code incore/models.py
def get_age(self):\n \"\"\"Returns the age.\"\"\"\n today = timezone.now()\n born = self.date_of_birth\n return (\n today.year - born.year - ((today.month, today.day) < (born.month, born.day))\n )\n
"},{"location":"reference/core/schemas/#core.schemas.User.get_family","title":"get_family(godfathers_depth=4, godchildren_depth=4)
","text":"Get the family of the user, with the given depth.
Parameters:
Name Type Description Defaultgodfathers_depth
NonNegativeInt
The number of generations of godfathers to fetch
4
godchildren_depth
NonNegativeInt
The number of generations of godchildren to fetch
4
Returns:
Type Descriptionset[through]
A list of family relationships in this user's family
Source code incore/models.py
def get_family(\n self,\n godfathers_depth: NonNegativeInt = 4,\n godchildren_depth: NonNegativeInt = 4,\n) -> set[User.godfathers.through]:\n \"\"\"Get the family of the user, with the given depth.\n\n Args:\n godfathers_depth: The number of generations of godfathers to fetch\n godchildren_depth: The number of generations of godchildren to fetch\n\n Returns:\n A list of family relationships in this user's family\n \"\"\"\n res = []\n for depth, key, reverse_key in [\n (godfathers_depth, \"from_user_id\", \"to_user_id\"),\n (godchildren_depth, \"to_user_id\", \"from_user_id\"),\n ]:\n if depth == 0:\n continue\n links = list(User.godfathers.through.objects.filter(**{key: self.id}))\n res.extend(links)\n for _ in range(1, depth): # noqa: F402 we don't care about gettext here\n ids = [getattr(c, reverse_key) for c in links]\n links = list(\n User.godfathers.through.objects.filter(\n **{f\"{key}__in\": ids}\n ).exclude(id__in=[r.id for r in res])\n )\n if not links:\n break\n res.extend(links)\n return set(res)\n
"},{"location":"reference/core/schemas/#core.schemas.User.email_user","title":"email_user(subject, message, from_email=None, **kwargs)
","text":"Sends an email to this User.
Source code incore/models.py
def email_user(self, subject, message, from_email=None, **kwargs):\n \"\"\"Sends an email to this User.\"\"\"\n if from_email is None:\n from_email = settings.DEFAULT_FROM_EMAIL\n send_mail(subject, message, from_email, [self.email], **kwargs)\n
"},{"location":"reference/core/schemas/#core.schemas.User.generate_username","title":"generate_username()
","text":"Generates a unique username based on the first and last names.
For example: Guy Carlier gives gcarlier, and gcarlier1 if the first one exists.
Returns:
Type Descriptionstr
The generated username.
Source code incore/models.py
def generate_username(self) -> str:\n \"\"\"Generates a unique username based on the first and last names.\n\n For example: Guy Carlier gives gcarlier, and gcarlier1 if the first one exists.\n\n Returns:\n The generated username.\n \"\"\"\n\n def remove_accents(data):\n return \"\".join(\n x\n for x in unicodedata.normalize(\"NFKD\", data)\n if unicodedata.category(x)[0] == \"L\"\n ).lower()\n\n user_name = (\n remove_accents(self.first_name[0] + self.last_name)\n .encode(\"ascii\", \"ignore\")\n .decode(\"utf-8\")\n )\n un_set = [u.username for u in User.objects.all()]\n if user_name in un_set:\n i = 1\n while user_name + str(i) in un_set:\n i += 1\n user_name += str(i)\n self.username = user_name\n return user_name\n
"},{"location":"reference/core/schemas/#core.schemas.User.is_owner","title":"is_owner(obj)
","text":"Determine if the object is owned by the user.
Source code incore/models.py
def is_owner(self, obj):\n \"\"\"Determine if the object is owned by the user.\"\"\"\n if hasattr(obj, \"is_owned_by\") and obj.is_owned_by(self):\n return True\n if hasattr(obj, \"owner_group\") and self.is_in_group(pk=obj.owner_group.id):\n return True\n return self.is_root\n
"},{"location":"reference/core/schemas/#core.schemas.User.can_edit","title":"can_edit(obj)
","text":"Determine if the object can be edited by the user.
Source code incore/models.py
def can_edit(self, obj):\n \"\"\"Determine if the object can be edited by the user.\"\"\"\n if hasattr(obj, \"can_be_edited_by\") and obj.can_be_edited_by(self):\n return True\n if hasattr(obj, \"edit_groups\"):\n for pk in obj.edit_groups.values_list(\"pk\", flat=True):\n if self.is_in_group(pk=pk):\n return True\n if isinstance(obj, User) and obj == self:\n return True\n return self.is_owner(obj)\n
"},{"location":"reference/core/schemas/#core.schemas.User.can_view","title":"can_view(obj)
","text":"Determine if the object can be viewed by the user.
Source code incore/models.py
def can_view(self, obj):\n \"\"\"Determine if the object can be viewed by the user.\"\"\"\n if hasattr(obj, \"can_be_viewed_by\") and obj.can_be_viewed_by(self):\n return True\n if hasattr(obj, \"view_groups\"):\n for pk in obj.view_groups.values_list(\"pk\", flat=True):\n if self.is_in_group(pk=pk):\n return True\n return self.can_edit(obj)\n
"},{"location":"reference/core/schemas/#core.schemas.User.clubs_with_rights","title":"clubs_with_rights()
","text":"The list of clubs where the user has rights
Source code incore/models.py
@cached_property\ndef clubs_with_rights(self) -> list[Club]:\n \"\"\"The list of clubs where the user has rights\"\"\"\n memberships = self.memberships.ongoing().board().select_related(\"club\")\n return [m.club for m in memberships]\n
"},{"location":"reference/core/schemas/#core.schemas.SimpleUserSchema","title":"SimpleUserSchema
","text":" Bases: ModelSchema
A schema with the minimum amount of information to represent a user.
"},{"location":"reference/core/schemas/#core.schemas.UserProfileSchema","title":"UserProfileSchema
","text":" Bases: ModelSchema
The necessary information to show a user profile
"},{"location":"reference/core/schemas/#core.schemas.SithFileSchema","title":"SithFileSchema
","text":" Bases: ModelSchema
GroupSchema
","text":" Bases: ModelSchema
UserFilterSchema
","text":" Bases: FilterSchema
MarkdownSchema
","text":" Bases: Schema
FamilyGodfatherSchema
","text":" Bases: Schema
UserFamilySchema
","text":" Bases: Schema
Represent a graph of a user's family
"},{"location":"reference/core/views/","title":"Views","text":""},{"location":"reference/core/views/#core.views.GenericContentPermissionMixinBuilder","title":"GenericContentPermissionMixinBuilder
","text":" Bases: View
Used to build permission mixins.
This view protect any child view that would be showing an object that is restricted based on two properties.
Attributes:
Name Type Descriptionraised_error
permission to be raised
"},{"location":"reference/core/views/#core.views.GenericContentPermissionMixinBuilder.permission_function","title":"permission_function(obj, user)
staticmethod
","text":"Function to test permission with.
Source code incore/views/__init__.py
@staticmethod\ndef permission_function(obj: Any, user: User) -> bool:\n \"\"\"Function to test permission with.\"\"\"\n return False\n
"},{"location":"reference/core/views/#core.views.CanCreateMixin","title":"CanCreateMixin
","text":" Bases: View
Protect any child view that would create an object.
Raises:
Type DescriptionPermissionDenied
If the user has not the necessary permission to create the object of the view.
"},{"location":"reference/core/views/#core.views.CanEditPropMixin","title":"CanEditPropMixin
","text":" Bases: GenericContentPermissionMixinBuilder
Ensure the user has owner permissions on the child view object.
In other word, you can make a view with this view as parent, and it will be retricted to the users that are in the object's owner_group or that pass the obj.can_be_viewed_by
test.
Raises:
Type DescriptionPermissionDenied
If the user cannot see the object
"},{"location":"reference/core/views/#core.views.CanEditMixin","title":"CanEditMixin
","text":" Bases: GenericContentPermissionMixinBuilder
Ensure the user has permission to edit this view's object.
Raises:
Type DescriptionPermissionDenied
if the user cannot edit this view's object.
"},{"location":"reference/core/views/#core.views.CanViewMixin","title":"CanViewMixin
","text":" Bases: GenericContentPermissionMixinBuilder
Ensure the user has permission to view this view's object.
Raises:
Type DescriptionPermissionDenied
if the user cannot edit this view's object.
"},{"location":"reference/core/views/#core.views.UserIsRootMixin","title":"UserIsRootMixin
","text":" Bases: GenericContentPermissionMixinBuilder
Allow only root admins.
Raises:
Type DescriptionPermissionDenied
if the user isn't root
"},{"location":"reference/core/views/#core.views.FormerSubscriberMixin","title":"FormerSubscriberMixin
","text":" Bases: AccessMixin
Check if the user was at least an old subscriber.
Raises:
Type DescriptionPermissionDenied
if the user never subscribed.
"},{"location":"reference/core/views/#core.views.SubscriberMixin","title":"SubscriberMixin
","text":" Bases: AccessMixin
TabedViewMixin
","text":" Bases: View
Basic functions for displaying tabs in the template.
"},{"location":"reference/core/views/#core.views.QuickNotifMixin","title":"QuickNotifMixin
","text":""},{"location":"reference/core/views/#core.views.QuickNotifMixin.get_context_data","title":"get_context_data(**kwargs)
","text":"Add quick notifications to context.
Source code incore/views/__init__.py
def get_context_data(self, **kwargs):\n \"\"\"Add quick notifications to context.\"\"\"\n kwargs = super().get_context_data(**kwargs)\n kwargs[\"quick_notifs\"] = []\n for n in self.quick_notif_list:\n kwargs[\"quick_notifs\"].append(settings.SITH_QUICK_NOTIF[n])\n for key, val in settings.SITH_QUICK_NOTIF.items():\n for gk in self.request.GET:\n if key == gk:\n kwargs[\"quick_notifs\"].append(val)\n return kwargs\n
"},{"location":"reference/core/views/#core.views.DetailFormView","title":"DetailFormView
","text":" Bases: SingleObjectMixin
, FormView
Class that allow both a detail view and a form view.
"},{"location":"reference/core/views/#core.views.DetailFormView.get_object","title":"get_object()
","text":"Get current group from id in url.
Source code incore/views/__init__.py
def get_object(self):\n \"\"\"Get current group from id in url.\"\"\"\n return self.cached_object\n
"},{"location":"reference/core/views/#core.views.DetailFormView.cached_object","title":"cached_object()
","text":"Optimisation on group retrieval.
Source code incore/views/__init__.py
@cached_property\ndef cached_object(self):\n \"\"\"Optimisation on group retrieval.\"\"\"\n return super().get_object()\n
"},{"location":"reference/core/views/#core.views.SithFile","title":"SithFile
","text":" Bases: Model
clean()
","text":"Cleans up the file.
Source code incore/models.py
def clean(self):\n \"\"\"Cleans up the file.\"\"\"\n super().clean()\n if \"/\" in self.name:\n raise ValidationError(_(\"Character '/' not authorized in name\"))\n if self == self.parent:\n raise ValidationError(_(\"Loop in folder tree\"), code=\"loop\")\n if self == self.parent or (\n self.parent is not None and self in self.get_parent_list()\n ):\n raise ValidationError(_(\"Loop in folder tree\"), code=\"loop\")\n if self.parent and self.parent.is_file:\n raise ValidationError(\n _(\"You can not make a file be a children of a non folder file\")\n )\n if (\n self.parent is None\n and SithFile.objects.exclude(id=self.id)\n .filter(parent=None, name=self.name)\n .exists()\n ) or (\n self.parent\n and self.parent.children.exclude(id=self.id).filter(name=self.name).exists()\n ):\n raise ValidationError(_(\"Duplicate file\"), code=\"duplicate\")\n if self.is_folder:\n if self.file:\n try:\n import imghdr\n\n if imghdr.what(None, self.file.read()) not in [\n \"gif\",\n \"png\",\n \"jpeg\",\n ]:\n self.file.delete()\n self.file = None\n except: # noqa E722 I don't know the exception that can be raised\n self.file = None\n self.mime_type = \"inode/directory\"\n if self.is_file and (self.file is None or self.file == \"\"):\n raise ValidationError(_(\"You must provide a file\"))\n
"},{"location":"reference/core/views/#core.views.SithFile.apply_rights_recursively","title":"apply_rights_recursively(*, only_folders=False)
","text":"Apply the rights of this file to all children recursively.
Parameters:
Name Type Description Defaultonly_folders
bool
If True, only apply the rights to SithFiles that are folders.
False
Source code in core/models.py
def apply_rights_recursively(self, *, only_folders: bool = False) -> None:\n \"\"\"Apply the rights of this file to all children recursively.\n\n Args:\n only_folders: If True, only apply the rights to SithFiles that are folders.\n \"\"\"\n file_ids = []\n explored_ids = [self.id]\n while len(explored_ids) > 0: # find all children recursively\n file_ids.extend(explored_ids)\n next_level = SithFile.objects.filter(parent_id__in=explored_ids)\n if only_folders:\n next_level = next_level.filter(is_folder=True)\n explored_ids = list(next_level.values_list(\"id\", flat=True))\n for through in (SithFile.view_groups.through, SithFile.edit_groups.through):\n # force evaluation. Without this, the iterator yields nothing\n groups = list(\n through.objects.filter(sithfile_id=self.id).values_list(\n \"group_id\", flat=True\n )\n )\n # delete previous rights\n through.objects.filter(sithfile_id__in=file_ids).delete()\n through.objects.bulk_create( # create new rights\n [through(sithfile_id=f, group_id=g) for f in file_ids for g in groups]\n )\n
"},{"location":"reference/core/views/#core.views.SithFile.copy_rights","title":"copy_rights()
","text":"Copy, if possible, the rights of the parent folder.
Source code incore/models.py
def copy_rights(self):\n \"\"\"Copy, if possible, the rights of the parent folder.\"\"\"\n if self.parent is not None:\n self.edit_groups.set(self.parent.edit_groups.all())\n self.view_groups.set(self.parent.view_groups.all())\n
"},{"location":"reference/core/views/#core.views.SithFile.move_to","title":"move_to(parent)
","text":"Move a file to a new parent. parent
must be a SithFile with the is_folder=True
property. Otherwise, this function doesn't change anything. This is done only at the DB level, so that it's very fast for the user. Indeed, this function doesn't modify SithFiles recursively, so it stays efficient even with top-level folders.
core/models.py
def move_to(self, parent):\n \"\"\"Move a file to a new parent.\n `parent` must be a SithFile with the `is_folder=True` property. Otherwise, this function doesn't change\n anything.\n This is done only at the DB level, so that it's very fast for the user. Indeed, this function doesn't modify\n SithFiles recursively, so it stays efficient even with top-level folders.\n \"\"\"\n if not parent.is_folder:\n return\n self.parent = parent\n self.clean()\n self.save()\n
"},{"location":"reference/core/views/#core.views.MultipleFileInput","title":"MultipleFileInput
","text":" Bases: ClearableFileInput
MultipleFileField(*args, **kwargs)
","text":" Bases: _MultipleFieldMixin
, FileField
core/views/files.py
def __init__(self, *args, **kwargs):\n kwargs.setdefault(\"widget\", MultipleFileInput())\n super().__init__(*args, **kwargs)\n
"},{"location":"reference/core/views/#core.views.MultipleImageField","title":"MultipleImageField(*args, **kwargs)
","text":" Bases: _MultipleFieldMixin
, ImageField
core/views/files.py
def __init__(self, *args, **kwargs):\n kwargs.setdefault(\"widget\", MultipleFileInput())\n super().__init__(*args, **kwargs)\n
"},{"location":"reference/core/views/#core.views.AddFilesForm","title":"AddFilesForm
","text":" Bases: Form
FileListView
","text":" Bases: ListView
FileEditView
","text":" Bases: CanEditMixin
, UpdateView
FileEditPropForm
","text":" Bases: ModelForm
FileEditPropView
","text":" Bases: CanEditPropMixin
, UpdateView
FileView
","text":" Bases: CanViewMixin
, DetailView
, FormMixin
Handle the upload of new files into a folder.
"},{"location":"reference/core/views/#core.views.FileView.handle_clipboard","title":"handle_clipboard(request, obj)
staticmethod
","text":"Handle the clipboard in the view.
This method can fail, since it does not catch the exceptions coming from below, allowing proper handling in the calling view. Use this method like this:
FileView.handle_clipboard(request, self.object)\n
request
is usually the self.request obj in your view obj
is the SithFile object you want to put in the clipboard, or where you want to paste the clipboard
core/views/files.py
@staticmethod\ndef handle_clipboard(request, obj):\n \"\"\"Handle the clipboard in the view.\n\n This method can fail, since it does not catch the exceptions coming from\n below, allowing proper handling in the calling view.\n Use this method like this:\n\n FileView.handle_clipboard(request, self.object)\n\n `request` is usually the self.request obj in your view\n `obj` is the SithFile object you want to put in the clipboard, or\n where you want to paste the clipboard\n \"\"\"\n if \"delete\" in request.POST:\n for f_id in request.POST.getlist(\"file_list\"):\n file = SithFile.objects.filter(id=f_id).first()\n if file:\n file.delete()\n if \"clear\" in request.POST:\n request.session[\"clipboard\"] = []\n if \"cut\" in request.POST:\n for f_id_str in request.POST.getlist(\"file_list\"):\n f_id = int(f_id_str)\n if (\n f_id in [c.id for c in obj.children.all()]\n and f_id not in request.session[\"clipboard\"]\n ):\n request.session[\"clipboard\"].append(f_id)\n if \"paste\" in request.POST:\n for f_id in request.session[\"clipboard\"]:\n file = SithFile.objects.filter(id=f_id).first()\n if file:\n file.move_to(obj)\n request.session[\"clipboard\"] = []\n request.session.modified = True\n
"},{"location":"reference/core/views/#core.views.FileDeleteView","title":"FileDeleteView
","text":" Bases: CanEditPropMixin
, DeleteView
FileModerationView
","text":" Bases: TemplateView
FileModerateView
","text":" Bases: CanEditPropMixin
, SingleObjectMixin
RealGroup
","text":" Bases: Group
RealGroups are created by the developer.
Most of the time they match a number in settings to be easily used for permissions.
"},{"location":"reference/core/views/#core.views.EditMembersForm","title":"EditMembersForm(*args, **kwargs)
","text":" Bases: Form
Add and remove members from a Group.
Source code incore/views/group.py
def __init__(self, *args, **kwargs):\n self.current_users = kwargs.pop(\"users\", [])\n super().__init__(*args, **kwargs)\n\n self.fields[\"users_added\"] = forms.ModelMultipleChoiceField(\n label=_(\"Users to add to group\"),\n help_text=_(\"Search users to add (one or more).\"),\n required=False,\n widget=AutoCompleteSelectMultipleUser,\n queryset=User.objects.exclude(id__in=self.current_users).all(),\n )\n\n self.fields[\"users_removed\"] = forms.ModelMultipleChoiceField(\n User.objects.filter(id__in=self.current_users).all(),\n label=_(\"Users to remove from group\"),\n required=False,\n widget=forms.CheckboxSelectMultiple,\n )\n
"},{"location":"reference/core/views/#core.views.GroupListView","title":"GroupListView
","text":" Bases: CanEditMixin
, ListView
Displays the Group list.
"},{"location":"reference/core/views/#core.views.GroupEditView","title":"GroupEditView
","text":" Bases: CanEditMixin
, UpdateView
Edit infos of a Group.
"},{"location":"reference/core/views/#core.views.GroupCreateView","title":"GroupCreateView
","text":" Bases: CanCreateMixin
, CreateView
Add a new Group.
"},{"location":"reference/core/views/#core.views.GroupTemplateView","title":"GroupTemplateView
","text":" Bases: CanEditMixin
, DetailFormView
Display all users in a given Group Allow adding and removing users from it.
"},{"location":"reference/core/views/#core.views.GroupDeleteView","title":"GroupDeleteView
","text":" Bases: CanEditMixin
, DeleteView
Delete a Group.
"},{"location":"reference/core/views/#core.views.LockError","title":"LockError
","text":" Bases: Exception
There was a lock error on the object.
"},{"location":"reference/core/views/#core.views.Page","title":"Page
","text":" Bases: Model
The page class to build a Wiki Each page may have a parent and it's URL is of the form my.site/page/// It has an ID field, but don't use it, since it's only there for DB part, and because compound primary key is awkward! Prefere querying pages with Page.get_page_by_full_name().
Be careful with the _full_name attribute: this field may not be valid until you call save(). It's made for fast query, but don't rely on it when playing with a Page object, use get_full_name() instead!
"},{"location":"reference/core/views/#core.views.Page.save","title":"save(*args, **kwargs)
","text":"Performs some needed actions before and after saving a page in database.
Source code incore/models.py
def save(self, *args, **kwargs):\n \"\"\"Performs some needed actions before and after saving a page in database.\"\"\"\n locked = kwargs.pop(\"force_lock\", False)\n if not locked:\n locked = self.is_locked()\n if not locked:\n raise NotLocked(\"The page is not locked and thus can not be saved\")\n self.full_clean()\n if not self.id:\n super().save(\n *args, **kwargs\n ) # Save a first time to correctly set _full_name\n # This reset the _full_name just before saving to maintain a coherent field quicker for queries than the\n # recursive method\n # It also update all the children to maintain correct names\n self._full_name = self.get_full_name()\n for c in self.children.all():\n c.save()\n super().save(*args, **kwargs)\n self.unset_lock()\n
"},{"location":"reference/core/views/#core.views.Page.get_page_by_full_name","title":"get_page_by_full_name(name)
staticmethod
","text":"Quicker to get a page with that method rather than building the request every time.
Source code incore/models.py
@staticmethod\ndef get_page_by_full_name(name):\n \"\"\"Quicker to get a page with that method rather than building the request every time.\"\"\"\n return Page.objects.filter(_full_name=name).first()\n
"},{"location":"reference/core/views/#core.views.Page.clean","title":"clean()
","text":"Cleans up only the name for the moment, but this can be used to make any treatment before saving the object.
Source code incore/models.py
def clean(self):\n \"\"\"Cleans up only the name for the moment, but this can be used to make any treatment before saving the object.\"\"\"\n if \"/\" in self.name:\n self.name = self.name.split(\"/\")[-1]\n if (\n Page.objects.exclude(pk=self.pk)\n .filter(_full_name=self.get_full_name())\n .exists()\n ):\n raise ValidationError(_(\"Duplicate page\"), code=\"duplicate\")\n super().clean()\n if self.parent is not None and self in self.get_parent_list():\n raise ValidationError(_(\"Loop in page tree\"), code=\"loop\")\n
"},{"location":"reference/core/views/#core.views.Page.is_locked","title":"is_locked()
","text":"Is True if the page is locked, False otherwise.
This is where the timeout is handled, so a locked page for which the timeout is reach will be unlocked and this function will return False.
Source code incore/models.py
def is_locked(self):\n \"\"\"Is True if the page is locked, False otherwise.\n\n This is where the timeout is handled,\n so a locked page for which the timeout is reach will be unlocked and this\n function will return False.\n \"\"\"\n if self.lock_timeout and (\n timezone.now() - self.lock_timeout > timedelta(minutes=5)\n ):\n self.unset_lock()\n return (\n self.lock_user\n and self.lock_timeout\n and (timezone.now() - self.lock_timeout < timedelta(minutes=5))\n )\n
"},{"location":"reference/core/views/#core.views.Page.set_lock","title":"set_lock(user)
","text":"Sets a lock on the current page or raise an AlreadyLocked exception.
Source code incore/models.py
def set_lock(self, user):\n \"\"\"Sets a lock on the current page or raise an AlreadyLocked exception.\"\"\"\n if self.is_locked() and self.get_lock() != user:\n raise AlreadyLocked(\"The page is already locked by someone else\")\n self.lock_user = user\n self.lock_timeout = timezone.now()\n super().save()\n
"},{"location":"reference/core/views/#core.views.Page.set_lock_recursive","title":"set_lock_recursive(user)
","text":"Locks recursively all the child pages for editing properties.
Source code incore/models.py
def set_lock_recursive(self, user):\n \"\"\"Locks recursively all the child pages for editing properties.\"\"\"\n for p in self.children.all():\n p.set_lock_recursive(user)\n self.set_lock(user)\n
"},{"location":"reference/core/views/#core.views.Page.unset_lock_recursive","title":"unset_lock_recursive()
","text":"Unlocks recursively all the child pages.
Source code incore/models.py
def unset_lock_recursive(self):\n \"\"\"Unlocks recursively all the child pages.\"\"\"\n for p in self.children.all():\n p.unset_lock_recursive()\n self.unset_lock()\n
"},{"location":"reference/core/views/#core.views.Page.unset_lock","title":"unset_lock()
","text":"Always try to unlock, even if there is no lock.
Source code incore/models.py
def unset_lock(self):\n \"\"\"Always try to unlock, even if there is no lock.\"\"\"\n self.lock_user = None\n self.lock_timeout = None\n super().save()\n
"},{"location":"reference/core/views/#core.views.Page.get_lock","title":"get_lock()
","text":"Returns the page's mutex containing the time and the user in a dict.
Source code incore/models.py
def get_lock(self):\n \"\"\"Returns the page's mutex containing the time and the user in a dict.\"\"\"\n if self.lock_user:\n return self.lock_user\n raise NotLocked(\"The page is not locked and thus can not return its user\")\n
"},{"location":"reference/core/views/#core.views.Page.get_full_name","title":"get_full_name()
","text":"Computes the real full_name of the page based on its name and its parent's name You can and must rely on this function when working on a page object that is not freshly fetched from the DB (For example when treating a Page object coming from a form).
Source code incore/models.py
def get_full_name(self):\n \"\"\"Computes the real full_name of the page based on its name and its parent's name\n You can and must rely on this function when working on a page object that is not freshly fetched from the DB\n (For example when treating a Page object coming from a form).\n \"\"\"\n if self.parent is None:\n return self.name\n return f\"{self.parent.get_full_name()}/{self.name}\"\n
"},{"location":"reference/core/views/#core.views.PageRev","title":"PageRev
","text":" Bases: Model
True content of the page.
Each page object has a revisions field that is a list of PageRev, ordered by date. my_page.revisions.last() gives the PageRev object that is the most up-to-date, and thus, is the real content of the page. The content is in PageRev.title and PageRev.content .
"},{"location":"reference/core/views/#core.views.CanEditPagePropMixin","title":"CanEditPagePropMixin
","text":" Bases: CanEditPropMixin
PageListView
","text":" Bases: CanViewMixin
, ListView
PageView
","text":" Bases: CanViewMixin
, DetailView
PageHistView
","text":" Bases: CanViewMixin
, DetailView
PageRevView
","text":" Bases: CanViewMixin
, DetailView
PageCreateView
","text":" Bases: CanCreateMixin
, CreateView
PagePropView
","text":" Bases: CanEditPagePropMixin
, UpdateView
PageEditViewBase
","text":" Bases: CanEditMixin
, UpdateView
PageEditView
","text":" Bases: PageEditViewBase
PageDeleteView
","text":" Bases: CanEditPagePropMixin
, DeleteView
Notification
","text":" Bases: Model
Club
","text":" Bases: Model
The Club class, made as a tree to allow nice tidy organization.
"},{"location":"reference/core/views/#core.views.Club.check_loop","title":"check_loop()
","text":"Raise a validation error when a loop is found within the parent list.
Source code inclub/models.py
def check_loop(self):\n \"\"\"Raise a validation error when a loop is found within the parent list.\"\"\"\n objs = []\n cur = self\n while cur.parent is not None:\n if cur in objs:\n raise ValidationError(_(\"You can not make loops in clubs\"))\n objs.append(cur)\n cur = cur.parent\n
"},{"location":"reference/core/views/#core.views.Club.is_owned_by","title":"is_owned_by(user)
","text":"Method to see if that object can be super edited by the given user.
Source code inclub/models.py
def is_owned_by(self, user):\n \"\"\"Method to see if that object can be super edited by the given user.\"\"\"\n if user.is_anonymous:\n return False\n return user.is_board_member\n
"},{"location":"reference/core/views/#core.views.Club.can_be_edited_by","title":"can_be_edited_by(user)
","text":"Method to see if that object can be edited by the given user.
Source code inclub/models.py
def can_be_edited_by(self, user):\n \"\"\"Method to see if that object can be edited by the given user.\"\"\"\n return self.has_rights_in_club(user)\n
"},{"location":"reference/core/views/#core.views.Club.can_be_viewed_by","title":"can_be_viewed_by(user)
","text":"Method to see if that object can be seen by the given user.
Source code inclub/models.py
def can_be_viewed_by(self, user):\n \"\"\"Method to see if that object can be seen by the given user.\"\"\"\n sub = User.objects.filter(pk=user.pk).first()\n if sub is None:\n return False\n return sub.was_subscribed\n
"},{"location":"reference/core/views/#core.views.Club.get_membership_for","title":"get_membership_for(user)
","text":"Return the current membership the given user.
NoteThe result is cached.
Source code inclub/models.py
def get_membership_for(self, user: User) -> Membership | None:\n \"\"\"Return the current membership the given user.\n\n Note:\n The result is cached.\n \"\"\"\n if user.is_anonymous:\n return None\n membership = cache.get(f\"membership_{self.id}_{user.id}\")\n if membership == \"not_member\":\n return None\n if membership is None:\n membership = self.members.filter(user=user, end_date=None).first()\n if membership is None:\n cache.set(f\"membership_{self.id}_{user.id}\", \"not_member\")\n else:\n cache.set(f\"membership_{self.id}_{user.id}\", membership)\n return membership\n
"},{"location":"reference/core/views/#core.views.NotificationList","title":"NotificationList
","text":" Bases: ListView
User
","text":" Bases: AbstractBaseUser
Defines the base user class, useable in every app.
This is almost the same as the auth module AbstractUser since it inherits from it, but some fields are required, and the username is generated automatically with the name of the user (see generate_username()).
Added field: nick_name, date_of_birth Required fields: email, first_name, last_name, date_of_birth
"},{"location":"reference/core/views/#core.views.User.cached_groups","title":"cached_groups: list[Group]
property
","text":"Get the list of groups this user is in.
The result is cached for the default duration (should be 5 minutes)
Returns: A list of all the groups this user is in.
"},{"location":"reference/core/views/#core.views.User.is_in_group","title":"is_in_group(*, pk=None, name=None)
","text":"Check if this user is in the given group. Either a group id or a group name must be provided. If both are passed, only the id will be considered.
The group will be fetched using the given parameter. If no group is found, return False. If a group is found, check if this user is in the latter.
Returns:
Type Descriptionbool
True if the user is the group, else False
Source code incore/models.py
def is_in_group(self, *, pk: int | None = None, name: str | None = None) -> bool:\n \"\"\"Check if this user is in the given group.\n Either a group id or a group name must be provided.\n If both are passed, only the id will be considered.\n\n The group will be fetched using the given parameter.\n If no group is found, return False.\n If a group is found, check if this user is in the latter.\n\n Returns:\n True if the user is the group, else False\n \"\"\"\n if pk is not None:\n group: Optional[Group] = get_group(pk=pk)\n elif name is not None:\n group: Optional[Group] = get_group(name=name)\n else:\n raise ValueError(\"You must either provide the id or the name of the group\")\n if group is None:\n return False\n if group.id == settings.SITH_GROUP_PUBLIC_ID:\n return True\n if group.id == settings.SITH_GROUP_SUBSCRIBERS_ID:\n return self.is_subscribed\n if group.id == settings.SITH_GROUP_OLD_SUBSCRIBERS_ID:\n return self.was_subscribed\n if group.id == settings.SITH_GROUP_ROOT_ID:\n return self.is_root\n if group.is_meta:\n # check if this group is associated with a club\n group.__class__ = MetaGroup\n club = group.associated_club\n if club is None:\n return False\n membership = club.get_membership_for(self)\n if membership is None:\n return False\n if group.name.endswith(settings.SITH_MEMBER_SUFFIX):\n return True\n return membership.role > settings.SITH_MAXIMUM_FREE_ROLE\n return group in self.cached_groups\n
"},{"location":"reference/core/views/#core.views.User.age","title":"age()
","text":"Return the age this user has the day the method is called. If the user has not filled his age, return 0.
Source code incore/models.py
@cached_property\ndef age(self) -> int:\n \"\"\"Return the age this user has the day the method is called.\n If the user has not filled his age, return 0.\n \"\"\"\n if self.date_of_birth is None:\n return 0\n today = timezone.now()\n age = today.year - self.date_of_birth.year\n # remove a year if this year's birthday is yet to come\n age -= (today.month, today.day) < (\n self.date_of_birth.month,\n self.date_of_birth.day,\n )\n return age\n
"},{"location":"reference/core/views/#core.views.User.get_full_name","title":"get_full_name()
","text":"Returns the first_name plus the last_name, with a space in between.
Source code incore/models.py
def get_full_name(self):\n \"\"\"Returns the first_name plus the last_name, with a space in between.\"\"\"\n full_name = \"%s %s\" % (self.first_name, self.last_name)\n return full_name.strip()\n
"},{"location":"reference/core/views/#core.views.User.get_short_name","title":"get_short_name()
","text":"Returns the short name for the user.
Source code incore/models.py
def get_short_name(self):\n \"\"\"Returns the short name for the user.\"\"\"\n if self.nick_name:\n return self.nick_name\n return self.first_name + \" \" + self.last_name\n
"},{"location":"reference/core/views/#core.views.User.get_display_name","title":"get_display_name()
","text":"Returns the display name of the user.
A nickname if possible, otherwise, the full name.
Source code incore/models.py
def get_display_name(self) -> str:\n \"\"\"Returns the display name of the user.\n\n A nickname if possible, otherwise, the full name.\n \"\"\"\n if self.nick_name:\n return \"%s (%s)\" % (self.get_full_name(), self.nick_name)\n return self.get_full_name()\n
"},{"location":"reference/core/views/#core.views.User.get_age","title":"get_age()
","text":"Returns the age.
Source code incore/models.py
def get_age(self):\n \"\"\"Returns the age.\"\"\"\n today = timezone.now()\n born = self.date_of_birth\n return (\n today.year - born.year - ((today.month, today.day) < (born.month, born.day))\n )\n
"},{"location":"reference/core/views/#core.views.User.get_family","title":"get_family(godfathers_depth=4, godchildren_depth=4)
","text":"Get the family of the user, with the given depth.
Parameters:
Name Type Description Defaultgodfathers_depth
NonNegativeInt
The number of generations of godfathers to fetch
4
godchildren_depth
NonNegativeInt
The number of generations of godchildren to fetch
4
Returns:
Type Descriptionset[through]
A list of family relationships in this user's family
Source code incore/models.py
def get_family(\n self,\n godfathers_depth: NonNegativeInt = 4,\n godchildren_depth: NonNegativeInt = 4,\n) -> set[User.godfathers.through]:\n \"\"\"Get the family of the user, with the given depth.\n\n Args:\n godfathers_depth: The number of generations of godfathers to fetch\n godchildren_depth: The number of generations of godchildren to fetch\n\n Returns:\n A list of family relationships in this user's family\n \"\"\"\n res = []\n for depth, key, reverse_key in [\n (godfathers_depth, \"from_user_id\", \"to_user_id\"),\n (godchildren_depth, \"to_user_id\", \"from_user_id\"),\n ]:\n if depth == 0:\n continue\n links = list(User.godfathers.through.objects.filter(**{key: self.id}))\n res.extend(links)\n for _ in range(1, depth): # noqa: F402 we don't care about gettext here\n ids = [getattr(c, reverse_key) for c in links]\n links = list(\n User.godfathers.through.objects.filter(\n **{f\"{key}__in\": ids}\n ).exclude(id__in=[r.id for r in res])\n )\n if not links:\n break\n res.extend(links)\n return set(res)\n
"},{"location":"reference/core/views/#core.views.User.email_user","title":"email_user(subject, message, from_email=None, **kwargs)
","text":"Sends an email to this User.
Source code incore/models.py
def email_user(self, subject, message, from_email=None, **kwargs):\n \"\"\"Sends an email to this User.\"\"\"\n if from_email is None:\n from_email = settings.DEFAULT_FROM_EMAIL\n send_mail(subject, message, from_email, [self.email], **kwargs)\n
"},{"location":"reference/core/views/#core.views.User.generate_username","title":"generate_username()
","text":"Generates a unique username based on the first and last names.
For example: Guy Carlier gives gcarlier, and gcarlier1 if the first one exists.
Returns:
Type Descriptionstr
The generated username.
Source code incore/models.py
def generate_username(self) -> str:\n \"\"\"Generates a unique username based on the first and last names.\n\n For example: Guy Carlier gives gcarlier, and gcarlier1 if the first one exists.\n\n Returns:\n The generated username.\n \"\"\"\n\n def remove_accents(data):\n return \"\".join(\n x\n for x in unicodedata.normalize(\"NFKD\", data)\n if unicodedata.category(x)[0] == \"L\"\n ).lower()\n\n user_name = (\n remove_accents(self.first_name[0] + self.last_name)\n .encode(\"ascii\", \"ignore\")\n .decode(\"utf-8\")\n )\n un_set = [u.username for u in User.objects.all()]\n if user_name in un_set:\n i = 1\n while user_name + str(i) in un_set:\n i += 1\n user_name += str(i)\n self.username = user_name\n return user_name\n
"},{"location":"reference/core/views/#core.views.User.is_owner","title":"is_owner(obj)
","text":"Determine if the object is owned by the user.
Source code incore/models.py
def is_owner(self, obj):\n \"\"\"Determine if the object is owned by the user.\"\"\"\n if hasattr(obj, \"is_owned_by\") and obj.is_owned_by(self):\n return True\n if hasattr(obj, \"owner_group\") and self.is_in_group(pk=obj.owner_group.id):\n return True\n return self.is_root\n
"},{"location":"reference/core/views/#core.views.User.can_edit","title":"can_edit(obj)
","text":"Determine if the object can be edited by the user.
Source code incore/models.py
def can_edit(self, obj):\n \"\"\"Determine if the object can be edited by the user.\"\"\"\n if hasattr(obj, \"can_be_edited_by\") and obj.can_be_edited_by(self):\n return True\n if hasattr(obj, \"edit_groups\"):\n for pk in obj.edit_groups.values_list(\"pk\", flat=True):\n if self.is_in_group(pk=pk):\n return True\n if isinstance(obj, User) and obj == self:\n return True\n return self.is_owner(obj)\n
"},{"location":"reference/core/views/#core.views.User.can_view","title":"can_view(obj)
","text":"Determine if the object can be viewed by the user.
Source code incore/models.py
def can_view(self, obj):\n \"\"\"Determine if the object can be viewed by the user.\"\"\"\n if hasattr(obj, \"can_be_viewed_by\") and obj.can_be_viewed_by(self):\n return True\n if hasattr(obj, \"view_groups\"):\n for pk in obj.view_groups.values_list(\"pk\", flat=True):\n if self.is_in_group(pk=pk):\n return True\n return self.can_edit(obj)\n
"},{"location":"reference/core/views/#core.views.User.clubs_with_rights","title":"clubs_with_rights()
","text":"The list of clubs where the user has rights
Source code incore/models.py
@cached_property\ndef clubs_with_rights(self) -> list[Club]:\n \"\"\"The list of clubs where the user has rights\"\"\"\n memberships = self.memberships.ongoing().board().select_related(\"club\")\n return [m.club for m in memberships]\n
"},{"location":"reference/core/views/#core.views.Gift","title":"Gift
","text":" Bases: Model
Preferences
","text":" Bases: Model
SithLoginView
","text":" Bases: LoginView
The login View.
"},{"location":"reference/core/views/#core.views.SithPasswordChangeView","title":"SithPasswordChangeView
","text":" Bases: PasswordChangeView
Allows a user to change its password.
"},{"location":"reference/core/views/#core.views.SithPasswordChangeDoneView","title":"SithPasswordChangeDoneView
","text":" Bases: PasswordChangeDoneView
Allows a user to change its password.
"},{"location":"reference/core/views/#core.views.SithPasswordResetView","title":"SithPasswordResetView
","text":" Bases: PasswordResetView
Allows someone to enter an email address for resetting password.
"},{"location":"reference/core/views/#core.views.SithPasswordResetDoneView","title":"SithPasswordResetDoneView
","text":" Bases: PasswordResetDoneView
Confirm that the reset email has been sent.
"},{"location":"reference/core/views/#core.views.SithPasswordResetConfirmView","title":"SithPasswordResetConfirmView
","text":" Bases: PasswordResetConfirmView
Provide a reset password form.
"},{"location":"reference/core/views/#core.views.SithPasswordResetCompleteView","title":"SithPasswordResetCompleteView
","text":" Bases: PasswordResetCompleteView
Confirm the password has successfully been reset.
"},{"location":"reference/core/views/#core.views.UserCreationView","title":"UserCreationView
","text":" Bases: FormView
UserTabsMixin
","text":" Bases: TabedViewMixin
UserView
","text":" Bases: UserTabsMixin
, CanViewMixin
, DetailView
Display a user's profile.
"},{"location":"reference/core/views/#core.views.UserPicturesView","title":"UserPicturesView
","text":" Bases: UserTabsMixin
, CanViewMixin
, DetailView
Display a user's pictures.
"},{"location":"reference/core/views/#core.views.UserGodfathersView","title":"UserGodfathersView
","text":" Bases: UserTabsMixin
, CanViewMixin
, DetailView
, FormView
Display a user's godfathers.
"},{"location":"reference/core/views/#core.views.UserGodfathersTreeView","title":"UserGodfathersTreeView
","text":" Bases: UserTabsMixin
, CanViewMixin
, DetailView
Display a user's family tree.
"},{"location":"reference/core/views/#core.views.UserStatsView","title":"UserStatsView
","text":" Bases: UserTabsMixin
, CanViewMixin
, DetailView
Display a user's stats.
"},{"location":"reference/core/views/#core.views.UserMiniView","title":"UserMiniView
","text":" Bases: CanViewMixin
, DetailView
Display a user's profile.
"},{"location":"reference/core/views/#core.views.UserListView","title":"UserListView
","text":" Bases: ListView
, CanEditPropMixin
Displays the user list.
"},{"location":"reference/core/views/#core.views.UserUpdateProfileView","title":"UserUpdateProfileView
","text":" Bases: UserTabsMixin
, CanEditMixin
, UpdateView
Edit a user's profile.
"},{"location":"reference/core/views/#core.views.UserUpdateProfileView.remove_restricted_fields","title":"remove_restricted_fields(request)
","text":"Removes edit_once and board_only fields.
Source code incore/views/user.py
def remove_restricted_fields(self, request):\n \"\"\"Removes edit_once and board_only fields.\"\"\"\n for i in self.edit_once:\n if getattr(self.form.instance, i) and not (\n request.user.is_board_member or request.user.is_root\n ):\n self.form.fields.pop(i, None)\n for i in self.board_only:\n if not (request.user.is_board_member or request.user.is_root):\n self.form.fields.pop(i, None)\n
"},{"location":"reference/core/views/#core.views.UserClubView","title":"UserClubView
","text":" Bases: UserTabsMixin
, CanViewMixin
, DetailView
Display the user's club(s).
"},{"location":"reference/core/views/#core.views.UserPreferencesView","title":"UserPreferencesView
","text":" Bases: UserTabsMixin
, CanEditMixin
, UpdateView
Edit a user's preferences.
"},{"location":"reference/core/views/#core.views.UserUpdateGroupView","title":"UserUpdateGroupView
","text":" Bases: UserTabsMixin
, CanEditPropMixin
, UpdateView
Edit a user's groups.
"},{"location":"reference/core/views/#core.views.UserToolsView","title":"UserToolsView
","text":" Bases: LoginRequiredMixin
, QuickNotifMixin
, UserTabsMixin
, TemplateView
Displays the logged user's tools.
"},{"location":"reference/core/views/#core.views.UserAccountBase","title":"UserAccountBase
","text":" Bases: UserTabsMixin
, DetailView
Base class for UserAccount.
"},{"location":"reference/core/views/#core.views.UserAccountView","title":"UserAccountView
","text":" Bases: UserAccountBase
Display a user's account.
"},{"location":"reference/core/views/#core.views.UserAccountDetailView","title":"UserAccountDetailView
","text":" Bases: UserAccountBase
, YearMixin
, MonthMixin
Display a user's account for month.
"},{"location":"reference/core/views/#core.views.GiftCreateView","title":"GiftCreateView
","text":" Bases: CreateView
GiftDeleteView
","text":" Bases: CanEditPropMixin
, DeleteView
forbidden(request, exception)
","text":"Source code in core/views/__init__.py
def forbidden(request, exception):\n context = {\"next\": request.path, \"form\": LoginForm()}\n if popup := request.resolver_match.kwargs.get(\"popup\"):\n context[\"popup\"] = popup\n return HttpResponseForbidden(render(request, \"core/403.jinja\", context=context))\n
"},{"location":"reference/core/views/#core.views.not_found","title":"not_found(request, exception)
","text":"Source code in core/views/__init__.py
def not_found(request, exception):\n return HttpResponseNotFound(\n render(request, \"core/404.jinja\", context={\"exception\": exception})\n )\n
"},{"location":"reference/core/views/#core.views.internal_servor_error","title":"internal_servor_error(request)
","text":"Source code in core/views/__init__.py
def internal_servor_error(request):\n request.sentry_last_event_id = last_event_id\n return HttpResponseServerError(render(request, \"core/500.jinja\"))\n
"},{"location":"reference/core/views/#core.views.can_edit_prop","title":"can_edit_prop(obj, user)
","text":"Can the user edit the properties of the object.
Parameters:
Name Type Description Defaultobj
Any
Object to test for permission
requireduser
User
core.models.User to test permissions against
requiredReturns:
Type Descriptionbool
True if user is authorized to edit object properties else False
Examples:
if not can_edit_prop(self.object ,request.user):\n raise PermissionDenied\n
Source code in core/views/__init__.py
def can_edit_prop(obj: Any, user: User) -> bool:\n \"\"\"Can the user edit the properties of the object.\n\n Args:\n obj: Object to test for permission\n user: core.models.User to test permissions against\n\n Returns:\n True if user is authorized to edit object properties else False\n\n Examples:\n ```python\n if not can_edit_prop(self.object ,request.user):\n raise PermissionDenied\n ```\n \"\"\"\n return obj is None or user.is_owner(obj)\n
"},{"location":"reference/core/views/#core.views.can_edit","title":"can_edit(obj, user)
","text":"Can the user edit the object.
Parameters:
Name Type Description Defaultobj
Any
Object to test for permission
requireduser
User
core.models.User to test permissions against
requiredReturns:
Type Descriptionbool
True if user is authorized to edit object else False
Examples:
if not can_edit(self.object, request.user):\n raise PermissionDenied\n
Source code in core/views/__init__.py
def can_edit(obj: Any, user: User) -> bool:\n \"\"\"Can the user edit the object.\n\n Args:\n obj: Object to test for permission\n user: core.models.User to test permissions against\n\n Returns:\n True if user is authorized to edit object else False\n\n Examples:\n ```python\n if not can_edit(self.object, request.user):\n raise PermissionDenied\n ```\n \"\"\"\n if obj is None or user.can_edit(obj):\n return True\n return can_edit_prop(obj, user)\n
"},{"location":"reference/core/views/#core.views.can_view","title":"can_view(obj, user)
","text":"Can the user see the object.
Parameters:
Name Type Description Defaultobj
Any
Object to test for permission
requireduser
User
core.models.User to test permissions against
requiredReturns:
Type Descriptionbool
True if user is authorized to see object else False
Examples:
if not can_view(self.object ,request.user):\n raise PermissionDenied\n
Source code in core/views/__init__.py
def can_view(obj: Any, user: User) -> bool:\n \"\"\"Can the user see the object.\n\n Args:\n obj: Object to test for permission\n user: core.models.User to test permissions against\n\n Returns:\n True if user is authorized to see object else False\n\n Examples:\n ```python\n if not can_view(self.object ,request.user):\n raise PermissionDenied\n ```\n \"\"\"\n if obj is None or user.can_view(obj):\n return True\n return can_edit(obj, user)\n
"},{"location":"reference/core/views/#core.views.send_file","title":"send_file(request, file_id, file_class=SithFile, file_attr='file')
","text":"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.
Source code incore/views/files.py
def send_file(\n request: HttpRequest,\n file_id: int,\n file_class: type[SithFile] = SithFile,\n file_attr: str = \"file\",\n) -> HttpResponse:\n \"\"\"Send a protected file, if the user can see it.\n\n In prod, the server won't handle the download itself,\n but set the appropriate headers in the response to make the reverse-proxy\n deal with it.\n In debug mode, the server will directly send the file.\n \"\"\"\n f = get_object_or_404(file_class, id=file_id)\n if not can_view(f, request.user) and not is_logged_in_counter(request):\n raise PermissionDenied\n name = getattr(f, file_attr).name\n\n response = HttpResponse(\n headers={\"Content-Disposition\": f'inline; filename=\"{quote(name)}\"'}\n )\n if not settings.DEBUG:\n # When receiving a response with the Accel-Redirect header,\n # the reverse proxy will automatically handle the file sending.\n # This is really hard to test (thus isn't tested)\n # so please do not mess with this.\n response[\"Content-Type\"] = \"\" # automatically set by nginx\n response[\"X-Accel-Redirect\"] = quote(urljoin(settings.MEDIA_URL, name))\n return response\n\n filepath = settings.MEDIA_ROOT / name\n # check if file exists on disk\n if not filepath.exists():\n raise Http404\n with open(filepath, \"rb\") as filename:\n response.content = FileWrapper(filename)\n response[\"Content-Type\"] = mimetypes.guess_type(filepath)[0]\n response[\"Last-Modified\"] = http_date(f.date.timestamp())\n response[\"Content-Length\"] = filepath.stat().st_size\n return response\n
"},{"location":"reference/core/views/#core.views.index","title":"index(request, context=None)
","text":"Source code in core/views/site.py
def index(request, context=None):\n from com.views import NewsListView\n\n return NewsListView.as_view()(request)\n
"},{"location":"reference/core/views/#core.views.notification","title":"notification(request, notif_id)
","text":"Source code in core/views/site.py
def notification(request, notif_id):\n notif = Notification.objects.filter(id=notif_id).first()\n if notif:\n if notif.type not in settings.SITH_PERMANENT_NOTIFICATIONS:\n notif.viewed = True\n else:\n notif.callback()\n notif.save()\n return redirect(notif.url)\n return redirect(\"/\")\n
"},{"location":"reference/core/views/#core.views.search_user","title":"search_user(query)
","text":"Source code in core/views/site.py
def search_user(query):\n try:\n # slugify turns everything into ascii and every whitespace into -\n # it ends by removing duplicate - (so ' - ' will turn into '-')\n # replace('-', ' ') because search is whitespace based\n query = slugify(query).replace(\"-\", \" \")\n # TODO: is this necessary?\n query = html.escape(query)\n res = (\n SearchQuerySet()\n .models(User)\n .autocomplete(auto=query)\n .order_by(\"-last_login\")\n .load_all()[:20]\n )\n return [r.object for r in res]\n except TypeError:\n return []\n
"},{"location":"reference/core/views/#core.views.search_club","title":"search_club(query, *, as_json=False)
","text":"Source code in core/views/site.py
def search_club(query, *, as_json=False):\n clubs = []\n if query:\n clubs = Club.objects.filter(name__icontains=query).all()\n clubs = clubs[:5]\n if as_json:\n # Re-loads json to avoid double encoding by JsonResponse, but still benefit from serializers\n clubs = json.loads(serializers.serialize(\"json\", clubs, fields=(\"name\")))\n else:\n clubs = list(clubs)\n return clubs\n
"},{"location":"reference/core/views/#core.views.search_view","title":"search_view(request)
","text":"Source code in core/views/site.py
@login_required\ndef search_view(request):\n result = {\n \"users\": search_user(request.GET.get(\"query\", \"\")),\n \"clubs\": search_club(request.GET.get(\"query\", \"\")),\n }\n return render(request, \"core/search.jinja\", context={\"result\": result})\n
"},{"location":"reference/core/views/#core.views.search_user_json","title":"search_user_json(request)
","text":"Source code in core/views/site.py
@login_required\ndef search_user_json(request):\n result = {\"users\": search_user(request.GET.get(\"query\", \"\"))}\n return JsonResponse(result)\n
"},{"location":"reference/core/views/#core.views.search_json","title":"search_json(request)
","text":"Source code in core/views/site.py
@login_required\ndef search_json(request):\n result = {\n \"users\": search_user(request.GET.get(\"query\", \"\")),\n \"clubs\": search_club(request.GET.get(\"query\", \"\"), as_json=True),\n }\n return JsonResponse(result)\n
"},{"location":"reference/core/views/#core.views.logout","title":"logout(request)
","text":"The logout view.
Source code incore/views/user.py
def logout(request):\n \"\"\"The logout view.\"\"\"\n return views.logout_then_login(request)\n
"},{"location":"reference/core/views/#core.views.password_root_change","title":"password_root_change(request, user_id)
","text":"Allows a root user to change someone's password.
Source code incore/views/user.py
def password_root_change(request, user_id):\n \"\"\"Allows a root user to change someone's password.\"\"\"\n if not request.user.is_root:\n raise PermissionDenied\n user = User.objects.filter(id=user_id).first()\n if not user:\n raise Http404(\"User not found\")\n if request.method == \"POST\":\n form = views.SetPasswordForm(user=user, data=request.POST)\n if form.is_valid():\n form.save()\n return redirect(\"core:password_change_done\")\n else:\n form = views.SetPasswordForm(user=user)\n return TemplateResponse(\n request, \"core/password_change.jinja\", {\"form\": form, \"target\": user}\n )\n
"},{"location":"reference/core/views/#core.views.delete_user_godfather","title":"delete_user_godfather(request, user_id, godfather_id, is_father)
","text":"Source code in core/views/user.py
def delete_user_godfather(request, user_id, godfather_id, is_father):\n user_is_admin = request.user.is_root or request.user.is_board_member\n if user_id != request.user.id and not user_is_admin:\n raise PermissionDenied()\n user = get_object_or_404(User, id=user_id)\n to_remove = get_object_or_404(User, id=godfather_id)\n if is_father:\n user.godfathers.remove(to_remove)\n else:\n user.godchildren.remove(to_remove)\n return redirect(\"core:user_godfathers\", user_id=user_id)\n
"},{"location":"reference/counter/models/","title":"Models","text":""},{"location":"reference/counter/models/#counter.models.CurrencyField","title":"CurrencyField(*args, **kwargs)
","text":" Bases: DecimalField
Custom database field used for currency.
Source code inaccounting/models.py
def __init__(self, *args, **kwargs):\n kwargs[\"max_digits\"] = 12\n kwargs[\"decimal_places\"] = 2\n super().__init__(*args, **kwargs)\n
"},{"location":"reference/counter/models/#counter.models.Club","title":"Club
","text":" Bases: Model
The Club class, made as a tree to allow nice tidy organization.
"},{"location":"reference/counter/models/#counter.models.Club.check_loop","title":"check_loop()
","text":"Raise a validation error when a loop is found within the parent list.
Source code inclub/models.py
def check_loop(self):\n \"\"\"Raise a validation error when a loop is found within the parent list.\"\"\"\n objs = []\n cur = self\n while cur.parent is not None:\n if cur in objs:\n raise ValidationError(_(\"You can not make loops in clubs\"))\n objs.append(cur)\n cur = cur.parent\n
"},{"location":"reference/counter/models/#counter.models.Club.is_owned_by","title":"is_owned_by(user)
","text":"Method to see if that object can be super edited by the given user.
Source code inclub/models.py
def is_owned_by(self, user):\n \"\"\"Method to see if that object can be super edited by the given user.\"\"\"\n if user.is_anonymous:\n return False\n return user.is_board_member\n
"},{"location":"reference/counter/models/#counter.models.Club.can_be_edited_by","title":"can_be_edited_by(user)
","text":"Method to see if that object can be edited by the given user.
Source code inclub/models.py
def can_be_edited_by(self, user):\n \"\"\"Method to see if that object can be edited by the given user.\"\"\"\n return self.has_rights_in_club(user)\n
"},{"location":"reference/counter/models/#counter.models.Club.can_be_viewed_by","title":"can_be_viewed_by(user)
","text":"Method to see if that object can be seen by the given user.
Source code inclub/models.py
def can_be_viewed_by(self, user):\n \"\"\"Method to see if that object can be seen by the given user.\"\"\"\n sub = User.objects.filter(pk=user.pk).first()\n if sub is None:\n return False\n return sub.was_subscribed\n
"},{"location":"reference/counter/models/#counter.models.Club.get_membership_for","title":"get_membership_for(user)
","text":"Return the current membership the given user.
NoteThe result is cached.
Source code inclub/models.py
def get_membership_for(self, user: User) -> Membership | None:\n \"\"\"Return the current membership the given user.\n\n Note:\n The result is cached.\n \"\"\"\n if user.is_anonymous:\n return None\n membership = cache.get(f\"membership_{self.id}_{user.id}\")\n if membership == \"not_member\":\n return None\n if membership is None:\n membership = self.members.filter(user=user, end_date=None).first()\n if membership is None:\n cache.set(f\"membership_{self.id}_{user.id}\", \"not_member\")\n else:\n cache.set(f\"membership_{self.id}_{user.id}\", membership)\n return membership\n
"},{"location":"reference/counter/models/#counter.models.ResizedImageField","title":"ResizedImageField(width=None, height=None, force_format=None, **kwargs)
","text":" Bases: ImageField
A field that automatically resizes images to a given size.
This field is useful for profile pictures or product icons, for example.
The final size of the image is determined by the width and height parameters :
If the force_format parameter is given, the image will be converted to this format.
Examples:
To resize an image with a height of 100px, without changing the ratio, and a format of WEBP :
class Product(models.Model):\n icon = ResizedImageField(height=100, force_format=\"WEBP\")\n
To explicitly resize an image to 100x100px (but possibly change the ratio) :
class Product(models.Model):\n icon = ResizedImageField(width=100, height=100)\n
Raises:
Type DescriptionFieldError
If neither width nor height is given
Parameters:
Name Type Description Defaultwidth
int | None
If given, the width of the resized image
None
height
int | None
If given, the height of the resized image
None
force_format
str | None
If given, the image will be converted to this format
None
Source code in core/fields.py
def __init__(\n self,\n width: int | None = None,\n height: int | None = None,\n force_format: str | None = None,\n **kwargs,\n):\n if width is None and height is None:\n raise FieldError(\n f\"{self.__class__.__name__} requires \"\n \"width, height or both, but got neither\"\n )\n self.width = width\n self.height = height\n self.force_format = force_format\n super().__init__(**kwargs)\n
"},{"location":"reference/counter/models/#counter.models.Group","title":"Group
","text":" Bases: Group
Implement both RealGroups and Meta groups.
Groups are sorted by their is_meta property
"},{"location":"reference/counter/models/#counter.models.Notification","title":"Notification
","text":" Bases: Model
User
","text":" Bases: AbstractBaseUser
Defines the base user class, useable in every app.
This is almost the same as the auth module AbstractUser since it inherits from it, but some fields are required, and the username is generated automatically with the name of the user (see generate_username()).
Added field: nick_name, date_of_birth Required fields: email, first_name, last_name, date_of_birth
"},{"location":"reference/counter/models/#counter.models.User.cached_groups","title":"cached_groups: list[Group]
property
","text":"Get the list of groups this user is in.
The result is cached for the default duration (should be 5 minutes)
Returns: A list of all the groups this user is in.
"},{"location":"reference/counter/models/#counter.models.User.is_in_group","title":"is_in_group(*, pk=None, name=None)
","text":"Check if this user is in the given group. Either a group id or a group name must be provided. If both are passed, only the id will be considered.
The group will be fetched using the given parameter. If no group is found, return False. If a group is found, check if this user is in the latter.
Returns:
Type Descriptionbool
True if the user is the group, else False
Source code incore/models.py
def is_in_group(self, *, pk: int | None = None, name: str | None = None) -> bool:\n \"\"\"Check if this user is in the given group.\n Either a group id or a group name must be provided.\n If both are passed, only the id will be considered.\n\n The group will be fetched using the given parameter.\n If no group is found, return False.\n If a group is found, check if this user is in the latter.\n\n Returns:\n True if the user is the group, else False\n \"\"\"\n if pk is not None:\n group: Optional[Group] = get_group(pk=pk)\n elif name is not None:\n group: Optional[Group] = get_group(name=name)\n else:\n raise ValueError(\"You must either provide the id or the name of the group\")\n if group is None:\n return False\n if group.id == settings.SITH_GROUP_PUBLIC_ID:\n return True\n if group.id == settings.SITH_GROUP_SUBSCRIBERS_ID:\n return self.is_subscribed\n if group.id == settings.SITH_GROUP_OLD_SUBSCRIBERS_ID:\n return self.was_subscribed\n if group.id == settings.SITH_GROUP_ROOT_ID:\n return self.is_root\n if group.is_meta:\n # check if this group is associated with a club\n group.__class__ = MetaGroup\n club = group.associated_club\n if club is None:\n return False\n membership = club.get_membership_for(self)\n if membership is None:\n return False\n if group.name.endswith(settings.SITH_MEMBER_SUFFIX):\n return True\n return membership.role > settings.SITH_MAXIMUM_FREE_ROLE\n return group in self.cached_groups\n
"},{"location":"reference/counter/models/#counter.models.User.age","title":"age()
","text":"Return the age this user has the day the method is called. If the user has not filled his age, return 0.
Source code incore/models.py
@cached_property\ndef age(self) -> int:\n \"\"\"Return the age this user has the day the method is called.\n If the user has not filled his age, return 0.\n \"\"\"\n if self.date_of_birth is None:\n return 0\n today = timezone.now()\n age = today.year - self.date_of_birth.year\n # remove a year if this year's birthday is yet to come\n age -= (today.month, today.day) < (\n self.date_of_birth.month,\n self.date_of_birth.day,\n )\n return age\n
"},{"location":"reference/counter/models/#counter.models.User.get_full_name","title":"get_full_name()
","text":"Returns the first_name plus the last_name, with a space in between.
Source code incore/models.py
def get_full_name(self):\n \"\"\"Returns the first_name plus the last_name, with a space in between.\"\"\"\n full_name = \"%s %s\" % (self.first_name, self.last_name)\n return full_name.strip()\n
"},{"location":"reference/counter/models/#counter.models.User.get_short_name","title":"get_short_name()
","text":"Returns the short name for the user.
Source code incore/models.py
def get_short_name(self):\n \"\"\"Returns the short name for the user.\"\"\"\n if self.nick_name:\n return self.nick_name\n return self.first_name + \" \" + self.last_name\n
"},{"location":"reference/counter/models/#counter.models.User.get_display_name","title":"get_display_name()
","text":"Returns the display name of the user.
A nickname if possible, otherwise, the full name.
Source code incore/models.py
def get_display_name(self) -> str:\n \"\"\"Returns the display name of the user.\n\n A nickname if possible, otherwise, the full name.\n \"\"\"\n if self.nick_name:\n return \"%s (%s)\" % (self.get_full_name(), self.nick_name)\n return self.get_full_name()\n
"},{"location":"reference/counter/models/#counter.models.User.get_age","title":"get_age()
","text":"Returns the age.
Source code incore/models.py
def get_age(self):\n \"\"\"Returns the age.\"\"\"\n today = timezone.now()\n born = self.date_of_birth\n return (\n today.year - born.year - ((today.month, today.day) < (born.month, born.day))\n )\n
"},{"location":"reference/counter/models/#counter.models.User.get_family","title":"get_family(godfathers_depth=4, godchildren_depth=4)
","text":"Get the family of the user, with the given depth.
Parameters:
Name Type Description Defaultgodfathers_depth
NonNegativeInt
The number of generations of godfathers to fetch
4
godchildren_depth
NonNegativeInt
The number of generations of godchildren to fetch
4
Returns:
Type Descriptionset[through]
A list of family relationships in this user's family
Source code incore/models.py
def get_family(\n self,\n godfathers_depth: NonNegativeInt = 4,\n godchildren_depth: NonNegativeInt = 4,\n) -> set[User.godfathers.through]:\n \"\"\"Get the family of the user, with the given depth.\n\n Args:\n godfathers_depth: The number of generations of godfathers to fetch\n godchildren_depth: The number of generations of godchildren to fetch\n\n Returns:\n A list of family relationships in this user's family\n \"\"\"\n res = []\n for depth, key, reverse_key in [\n (godfathers_depth, \"from_user_id\", \"to_user_id\"),\n (godchildren_depth, \"to_user_id\", \"from_user_id\"),\n ]:\n if depth == 0:\n continue\n links = list(User.godfathers.through.objects.filter(**{key: self.id}))\n res.extend(links)\n for _ in range(1, depth): # noqa: F402 we don't care about gettext here\n ids = [getattr(c, reverse_key) for c in links]\n links = list(\n User.godfathers.through.objects.filter(\n **{f\"{key}__in\": ids}\n ).exclude(id__in=[r.id for r in res])\n )\n if not links:\n break\n res.extend(links)\n return set(res)\n
"},{"location":"reference/counter/models/#counter.models.User.email_user","title":"email_user(subject, message, from_email=None, **kwargs)
","text":"Sends an email to this User.
Source code incore/models.py
def email_user(self, subject, message, from_email=None, **kwargs):\n \"\"\"Sends an email to this User.\"\"\"\n if from_email is None:\n from_email = settings.DEFAULT_FROM_EMAIL\n send_mail(subject, message, from_email, [self.email], **kwargs)\n
"},{"location":"reference/counter/models/#counter.models.User.generate_username","title":"generate_username()
","text":"Generates a unique username based on the first and last names.
For example: Guy Carlier gives gcarlier, and gcarlier1 if the first one exists.
Returns:
Type Descriptionstr
The generated username.
Source code incore/models.py
def generate_username(self) -> str:\n \"\"\"Generates a unique username based on the first and last names.\n\n For example: Guy Carlier gives gcarlier, and gcarlier1 if the first one exists.\n\n Returns:\n The generated username.\n \"\"\"\n\n def remove_accents(data):\n return \"\".join(\n x\n for x in unicodedata.normalize(\"NFKD\", data)\n if unicodedata.category(x)[0] == \"L\"\n ).lower()\n\n user_name = (\n remove_accents(self.first_name[0] + self.last_name)\n .encode(\"ascii\", \"ignore\")\n .decode(\"utf-8\")\n )\n un_set = [u.username for u in User.objects.all()]\n if user_name in un_set:\n i = 1\n while user_name + str(i) in un_set:\n i += 1\n user_name += str(i)\n self.username = user_name\n return user_name\n
"},{"location":"reference/counter/models/#counter.models.User.is_owner","title":"is_owner(obj)
","text":"Determine if the object is owned by the user.
Source code incore/models.py
def is_owner(self, obj):\n \"\"\"Determine if the object is owned by the user.\"\"\"\n if hasattr(obj, \"is_owned_by\") and obj.is_owned_by(self):\n return True\n if hasattr(obj, \"owner_group\") and self.is_in_group(pk=obj.owner_group.id):\n return True\n return self.is_root\n
"},{"location":"reference/counter/models/#counter.models.User.can_edit","title":"can_edit(obj)
","text":"Determine if the object can be edited by the user.
Source code incore/models.py
def can_edit(self, obj):\n \"\"\"Determine if the object can be edited by the user.\"\"\"\n if hasattr(obj, \"can_be_edited_by\") and obj.can_be_edited_by(self):\n return True\n if hasattr(obj, \"edit_groups\"):\n for pk in obj.edit_groups.values_list(\"pk\", flat=True):\n if self.is_in_group(pk=pk):\n return True\n if isinstance(obj, User) and obj == self:\n return True\n return self.is_owner(obj)\n
"},{"location":"reference/counter/models/#counter.models.User.can_view","title":"can_view(obj)
","text":"Determine if the object can be viewed by the user.
Source code incore/models.py
def can_view(self, obj):\n \"\"\"Determine if the object can be viewed by the user.\"\"\"\n if hasattr(obj, \"can_be_viewed_by\") and obj.can_be_viewed_by(self):\n return True\n if hasattr(obj, \"view_groups\"):\n for pk in obj.view_groups.values_list(\"pk\", flat=True):\n if self.is_in_group(pk=pk):\n return True\n return self.can_edit(obj)\n
"},{"location":"reference/counter/models/#counter.models.User.clubs_with_rights","title":"clubs_with_rights()
","text":"The list of clubs where the user has rights
Source code incore/models.py
@cached_property\ndef clubs_with_rights(self) -> list[Club]:\n \"\"\"The list of clubs where the user has rights\"\"\"\n memberships = self.memberships.ongoing().board().select_related(\"club\")\n return [m.club for m in memberships]\n
"},{"location":"reference/counter/models/#counter.models.Customer","title":"Customer
","text":" Bases: Model
Customer data of a User.
It adds some basic customers' information, such as the account ID, and is used by other accounting classes as reference to the customer, rather than using User.
"},{"location":"reference/counter/models/#counter.models.Customer.can_buy","title":"can_buy: bool
property
","text":"Check if whether this customer has the right to purchase any item.
This must be not confused with the Product.can_be_sold_to(user) method as the present method returns an information about a customer whereas the other tells something about the relation between a User (not a Customer, don't mix them) and a Product.
"},{"location":"reference/counter/models/#counter.models.Customer.save","title":"save(*args, allow_negative=False, is_selling=False, **kwargs)
","text":"is_selling : tell if the current action is a selling allow_negative : ignored if not a selling. Allow a selling to put the account in negative Those two parameters avoid blocking the save method of a customer if his account is negative.
Source code incounter/models.py
def save(self, *args, allow_negative=False, is_selling=False, **kwargs):\n \"\"\"is_selling : tell if the current action is a selling\n allow_negative : ignored if not a selling. Allow a selling to put the account in negative\n Those two parameters avoid blocking the save method of a customer if his account is negative.\n \"\"\"\n if self.amount < 0 and (is_selling and not allow_negative):\n raise ValidationError(_(\"Not enough money\"))\n super().save(*args, **kwargs)\n
"},{"location":"reference/counter/models/#counter.models.Customer.get_or_create","title":"get_or_create(user)
classmethod
","text":"Work in pretty much the same way as the usual get_or_create method, but with the default field replaced by some under the hood.
If the user has an account, return it as is. Else create a new account with no money on it and a new unique account id
Example : ::
user = User.objects.get(pk=1)\naccount, created = Customer.get_or_create(user)\nif created:\n print(f\"created a new account with id {account.id}\")\nelse:\n print(f\"user has already an account, with {account.id} \u20ac on it\"\n
Source code in counter/models.py
@classmethod\ndef get_or_create(cls, user: User) -> Tuple[Customer, bool]:\n \"\"\"Work in pretty much the same way as the usual get_or_create method,\n but with the default field replaced by some under the hood.\n\n If the user has an account, return it as is.\n Else create a new account with no money on it and a new unique account id\n\n Example : ::\n\n user = User.objects.get(pk=1)\n account, created = Customer.get_or_create(user)\n if created:\n print(f\"created a new account with id {account.id}\")\n else:\n print(f\"user has already an account, with {account.id} \u20ac on it\"\n \"\"\"\n if hasattr(user, \"customer\"):\n return user.customer, False\n\n # account_id are always a number with a letter appended\n account_id = (\n Customer.objects.order_by(Length(\"account_id\"), \"account_id\")\n .values(\"account_id\")\n .last()\n )\n if account_id is None:\n # legacy from the old site\n account = cls.objects.create(user=user, account_id=\"1504a\")\n return account, True\n\n account_id = account_id[\"account_id\"]\n account_num = int(account_id[:-1])\n while Customer.objects.filter(account_id=account_id).exists():\n # when entering the first iteration, we are using an already existing account id\n # so the loop should always execute at least one time\n account_num += 1\n account_id = f\"{account_num}{random.choice(string.ascii_lowercase)}\"\n\n account = cls.objects.create(user=user, account_id=account_id)\n return account, True\n
"},{"location":"reference/counter/models/#counter.models.BillingInfo","title":"BillingInfo
","text":" Bases: Model
Represent the billing information of a user, which are required by the 3D-Secure v2 system used by the etransaction module.
"},{"location":"reference/counter/models/#counter.models.BillingInfo.to_3dsv2_xml","title":"to_3dsv2_xml()
","text":"Convert the data from this model into a xml usable by the online paying service of the Cr\u00e9dit Agricole bank. see : https://www.ca-moncommerce.com/espace-client-mon-commerce/up2pay-e-transactions/ma-documentation/manuel-dintegration-focus-3ds-v2/principes-generaux/#integration-3dsv2-developpeur-webmaster
.
counter/models.py
def to_3dsv2_xml(self) -> str:\n \"\"\"Convert the data from this model into a xml usable\n by the online paying service of the Cr\u00e9dit Agricole bank.\n see : `https://www.ca-moncommerce.com/espace-client-mon-commerce/up2pay-e-transactions/ma-documentation/manuel-dintegration-focus-3ds-v2/principes-generaux/#integration-3dsv2-developpeur-webmaster`.\n \"\"\"\n data = {\n \"Address\": {\n \"FirstName\": self.first_name,\n \"LastName\": self.last_name,\n \"Address1\": self.address_1,\n \"ZipCode\": self.zip_code,\n \"City\": self.city,\n \"CountryCode\": self.country.numeric, # ISO-3166-1 numeric code\n \"MobilePhone\": self.phone_number.as_national.replace(\" \", \"\"),\n \"CountryCodeMobilePhone\": f\"+{self.phone_number.country_code}\",\n }\n }\n if self.address_2:\n data[\"Address\"][\"Address2\"] = self.address_2\n xml = dict2xml(data, wrap=\"Billing\", newlines=False)\n return '<?xml version=\"1.0\" encoding=\"UTF-8\" ?>' + xml\n
"},{"location":"reference/counter/models/#counter.models.AccountDumpQuerySet","title":"AccountDumpQuerySet
","text":" Bases: QuerySet
ongoing()
","text":"Filter dump operations that are not completed yet.
Source code incounter/models.py
def ongoing(self) -> Self:\n \"\"\"Filter dump operations that are not completed yet.\"\"\"\n return self.filter(dump_operation=None)\n
"},{"location":"reference/counter/models/#counter.models.AccountDump","title":"AccountDump
","text":" Bases: Model
The process of dumping an account.
"},{"location":"reference/counter/models/#counter.models.ProductType","title":"ProductType
","text":" Bases: Model
A product type.
Useful only for categorizing.
"},{"location":"reference/counter/models/#counter.models.ProductType.is_owned_by","title":"is_owned_by(user)
","text":"Method to see if that object can be edited by the given user.
Source code incounter/models.py
def is_owned_by(self, user):\n \"\"\"Method to see if that object can be edited by the given user.\"\"\"\n if user.is_anonymous:\n return False\n return user.is_in_group(pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID)\n
"},{"location":"reference/counter/models/#counter.models.Product","title":"Product
","text":" Bases: Model
A product, with all its related information.
"},{"location":"reference/counter/models/#counter.models.Product.is_owned_by","title":"is_owned_by(user)
","text":"Method to see if that object can be edited by the given user.
Source code incounter/models.py
def is_owned_by(self, user):\n \"\"\"Method to see if that object can be edited by the given user.\"\"\"\n if user.is_anonymous:\n return False\n return user.is_in_group(\n pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID\n ) or user.is_in_group(pk=settings.SITH_GROUP_COUNTER_ADMIN_ID)\n
"},{"location":"reference/counter/models/#counter.models.Product.can_be_sold_to","title":"can_be_sold_to(user)
","text":"Check if whether the user given in parameter has the right to buy this product or not.
This must be not confused with the Customer.can_buy() method as the present method returns an information about the relation between a User and a Product, whereas the other tells something about a Customer (and not a user, they are not the same model).
Returns:
Type Descriptionbool
True if the user can buy this product else False
WarningThis 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 :
user = User.objects.get(username=\"foobar\")\nproducts = [\n p\n for p in Product.objects.prefetch_related(\"buying_groups\")\n if p.can_be_sold_to(user)\n]\n
Source code in counter/models.py
def can_be_sold_to(self, user: User) -> bool:\n \"\"\"Check if whether the user given in parameter has the right to buy\n this product or not.\n\n This must be not confused with the Customer.can_buy()\n method as the present method returns an information\n about the relation between a User and a Product,\n whereas the other tells something about a Customer\n (and not a user, they are not the same model).\n\n Returns:\n True if the user can buy this product else False\n\n Warning:\n This performs a db query, thus you can quickly have\n a N+1 queries problem if you call it in a loop.\n Hopefully, you can avoid that if you prefetch the buying_groups :\n\n ```python\n user = User.objects.get(username=\"foobar\")\n products = [\n p\n for p in Product.objects.prefetch_related(\"buying_groups\")\n if p.can_be_sold_to(user)\n ]\n ```\n \"\"\"\n buying_groups = list(self.buying_groups.all())\n if not buying_groups:\n return True\n return any(user.is_in_group(pk=group.id) for group in buying_groups)\n
"},{"location":"reference/counter/models/#counter.models.CounterQuerySet","title":"CounterQuerySet
","text":" Bases: QuerySet
annotate_has_barman(user)
","text":"Annotate the queryset with the user_is_barman
field.
For each counter, this field has value True if the user is a barman of this counter, else False.
Parameters:
Name Type Description Defaultuser
User
the user we want to check if he is a barman
requiredExamples:
sli = User.objects.get(username=\"sli\")\ncounters = (\n Counter.objects\n .annotate_has_barman(sli) # add the user_has_barman boolean field\n .filter(has_annotated_barman=True) # keep only counters where this user is barman\n)\nprint(\"Sli est barman dans les comptoirs suivants :\")\nfor counter in counters:\n print(f\"- {counter.name}\")\n
Source code in counter/models.py
def annotate_has_barman(self, user: User) -> Self:\n \"\"\"Annotate the queryset with the `user_is_barman` field.\n\n For each counter, this field has value True if the user\n is a barman of this counter, else False.\n\n Args:\n user: the user we want to check if he is a barman\n\n Examples:\n ```python\n sli = User.objects.get(username=\"sli\")\n counters = (\n Counter.objects\n .annotate_has_barman(sli) # add the user_has_barman boolean field\n .filter(has_annotated_barman=True) # keep only counters where this user is barman\n )\n print(\"Sli est barman dans les comptoirs suivants :\")\n for counter in counters:\n print(f\"- {counter.name}\")\n ```\n \"\"\"\n subquery = user.counters.filter(pk=OuterRef(\"pk\"))\n return self.annotate(has_annotated_barman=Exists(subquery))\n
"},{"location":"reference/counter/models/#counter.models.CounterQuerySet.annotate_is_open","title":"annotate_is_open()
","text":"Annotate tue queryset with the is_open
field.
For each counter, if is_open=True
, then the counter is currently opened. Else the counter is closed.
counter/models.py
def annotate_is_open(self) -> Self:\n \"\"\"Annotate tue queryset with the `is_open` field.\n\n For each counter, if `is_open=True`, then the counter is currently opened.\n Else the counter is closed.\n \"\"\"\n return self.annotate(\n is_open=Exists(\n Permanency.objects.filter(counter_id=OuterRef(\"pk\"), end=None)\n )\n )\n
"},{"location":"reference/counter/models/#counter.models.CounterQuerySet.handle_timeout","title":"handle_timeout()
","text":"Disconnect the barmen who are inactive in the given counters.
Returns:
Type Descriptionint
The number of affected rows (ie, the number of timeouted permanences)
Source code incounter/models.py
def handle_timeout(self) -> int:\n \"\"\"Disconnect the barmen who are inactive in the given counters.\n\n Returns:\n The number of affected rows (ie, the number of timeouted permanences)\n \"\"\"\n timeout = timezone.now() - timedelta(minutes=settings.SITH_BARMAN_TIMEOUT)\n return Permanency.objects.filter(\n counter__in=self, end=None, activity__lt=timeout\n ).update(end=F(\"activity\"))\n
"},{"location":"reference/counter/models/#counter.models.Counter","title":"Counter
","text":" Bases: Model
gen_token()
","text":"Generate a new token for this counter.
Source code incounter/models.py
def gen_token(self) -> None:\n \"\"\"Generate a new token for this counter.\"\"\"\n self.token = \"\".join(\n random.choice(string.ascii_letters + string.digits) for _ in range(30)\n )\n self.save()\n
"},{"location":"reference/counter/models/#counter.models.Counter.barmen_list","title":"barmen_list()
","text":"Returns the barman list as list of User.
Source code incounter/models.py
@cached_property\ndef barmen_list(self) -> list[User]:\n \"\"\"Returns the barman list as list of User.\"\"\"\n return [\n p.user for p in self.permanencies.filter(end=None).select_related(\"user\")\n ]\n
"},{"location":"reference/counter/models/#counter.models.Counter.get_random_barman","title":"get_random_barman()
","text":"Return a random user being currently a barman.
Source code incounter/models.py
def get_random_barman(self) -> User:\n \"\"\"Return a random user being currently a barman.\"\"\"\n return random.choice(self.barmen_list)\n
"},{"location":"reference/counter/models/#counter.models.Counter.update_activity","title":"update_activity()
","text":"Update the barman activity to prevent timeout.
Source code incounter/models.py
def update_activity(self) -> None:\n \"\"\"Update the barman activity to prevent timeout.\"\"\"\n self.permanencies.filter(end=None).update(activity=timezone.now())\n
"},{"location":"reference/counter/models/#counter.models.Counter.can_refill","title":"can_refill()
","text":"Show if the counter authorize the refilling with physic money.
Source code incounter/models.py
def can_refill(self) -> bool:\n \"\"\"Show if the counter authorize the refilling with physic money.\"\"\"\n if self.type != \"BAR\":\n return False\n if self.id in SITH_COUNTER_OFFICES:\n # If the counter is either 'AE' or 'BdF', refills are authorized\n return True\n # at least one of the barmen is in the AE board\n ae = Club.objects.get(unix_name=SITH_MAIN_CLUB[\"unix_name\"])\n return any(ae.get_membership_for(barman) for barman in self.barmen_list)\n
"},{"location":"reference/counter/models/#counter.models.Counter.get_top_barmen","title":"get_top_barmen()
","text":"Return a QuerySet querying the office hours stats of all the barmen of all time of this counter, ordered by descending number of hours.
Each element of the QuerySet corresponds to a barman and has the following datacounter/models.py
def get_top_barmen(self) -> QuerySet:\n \"\"\"Return a QuerySet querying the office hours stats of all the barmen of all time\n of this counter, ordered by descending number of hours.\n\n Each element of the QuerySet corresponds to a barman and has the following data :\n - the full name (first name + last name) of the barman\n - the nickname of the barman\n - the promo of the barman\n - the total number of office hours the barman did attend\n \"\"\"\n return (\n self.permanencies.exclude(end=None)\n .annotate(\n name=Concat(F(\"user__first_name\"), Value(\" \"), F(\"user__last_name\"))\n )\n .annotate(nickname=F(\"user__nick_name\"))\n .annotate(promo=F(\"user__promo\"))\n .values(\"user\", \"name\", \"nickname\", \"promo\")\n .annotate(perm_sum=Sum(F(\"end\") - F(\"start\")))\n .exclude(perm_sum=None)\n .order_by(\"-perm_sum\")\n )\n
"},{"location":"reference/counter/models/#counter.models.Counter.get_top_customers","title":"get_top_customers(since=None)
","text":"Return a QuerySet querying the money spent by customers of this counter since the specified date, ordered by descending amount of money spent.
Each element of the QuerySet corresponds to a customer and has the following data :
Parameters:
Name Type Description Defaultsince
datetime | date | None
timestamp from which to perform the calculation
None
Source code in counter/models.py
def get_top_customers(self, since: datetime | date | None = None) -> QuerySet:\n \"\"\"Return a QuerySet querying the money spent by customers of this counter\n since the specified date, ordered by descending amount of money spent.\n\n Each element of the QuerySet corresponds to a customer and has the following data :\n\n - the full name (first name + last name) of the customer\n - the nickname of the customer\n - the amount of money spent by the customer\n\n Args:\n since: timestamp from which to perform the calculation\n \"\"\"\n if since is None:\n since = get_start_of_semester()\n if isinstance(since, date):\n since = datetime(since.year, since.month, since.day, tzinfo=tz.utc)\n return (\n self.sellings.filter(date__gte=since)\n .annotate(\n name=Concat(\n F(\"customer__user__first_name\"),\n Value(\" \"),\n F(\"customer__user__last_name\"),\n )\n )\n .annotate(nickname=F(\"customer__user__nick_name\"))\n .annotate(promo=F(\"customer__user__promo\"))\n .annotate(user=F(\"customer__user\"))\n .values(\"user\", \"promo\", \"name\", \"nickname\")\n .annotate(\n selling_sum=Sum(\n F(\"unit_price\") * F(\"quantity\"), output_field=CurrencyField()\n )\n )\n .filter(selling_sum__gt=0)\n .order_by(\"-selling_sum\")\n )\n
"},{"location":"reference/counter/models/#counter.models.Counter.get_total_sales","title":"get_total_sales(since=None)
","text":"Compute and return the total turnover of this counter since the given date.
By default, the date is the start of the current semester.
Parameters:
Name Type Description Defaultsince
datetime | date | None
timestamp from which to perform the calculation
None
Returns:
Type DescriptionCurrencyField
Total revenue earned at this counter.
Source code incounter/models.py
def get_total_sales(self, since: datetime | date | None = None) -> CurrencyField:\n \"\"\"Compute and return the total turnover of this counter since the given date.\n\n By default, the date is the start of the current semester.\n\n Args:\n since: timestamp from which to perform the calculation\n\n Returns:\n Total revenue earned at this counter.\n \"\"\"\n if since is None:\n since = get_start_of_semester()\n if isinstance(since, date):\n since = datetime(since.year, since.month, since.day, tzinfo=tz.utc)\n return self.sellings.filter(date__gte=since).aggregate(\n total=Sum(\n F(\"quantity\") * F(\"unit_price\"),\n default=0,\n output_field=CurrencyField(),\n )\n )[\"total\"]\n
"},{"location":"reference/counter/models/#counter.models.RefillingQuerySet","title":"RefillingQuerySet
","text":" Bases: QuerySet
annotate_total()
","text":"Annotate the Queryset with the total amount.
The total is just the sum of the amounts for each row. If no grouping is involved (like in most queries), this is just the same as doing nothing and fetching the amount
attribute.
However, it may be useful when there is a group by
clause in the query, or when other models are queried and having a common interface is helpful (e.g. Selling.objects.annotate_total()
and Refilling.objects.annotate_total()
will both have the total
field).
counter/models.py
def annotate_total(self) -> Self:\n \"\"\"Annotate the Queryset with the total amount.\n\n The total is just the sum of the amounts for each row.\n If no grouping is involved (like in most queries),\n this is just the same as doing nothing and fetching the\n `amount` attribute.\n\n However, it may be useful when there is a `group by` clause\n in the query, or when other models are queried and having\n a common interface is helpful (e.g. `Selling.objects.annotate_total()`\n and `Refilling.objects.annotate_total()` will both have the `total` field).\n \"\"\"\n return self.annotate(total=Sum(\"amount\"))\n
"},{"location":"reference/counter/models/#counter.models.Refilling","title":"Refilling
","text":" Bases: Model
Handle the refilling.
"},{"location":"reference/counter/models/#counter.models.SellingQuerySet","title":"SellingQuerySet
","text":" Bases: QuerySet
annotate_total()
","text":"Annotate the Queryset with the total amount of the sales.
The total is considered as the sum of (unit_price * quantity).
Source code incounter/models.py
def annotate_total(self) -> Self:\n \"\"\"Annotate the Queryset with the total amount of the sales.\n\n The total is considered as the sum of (unit_price * quantity).\n \"\"\"\n return self.annotate(total=Sum(F(\"unit_price\") * F(\"quantity\")))\n
"},{"location":"reference/counter/models/#counter.models.Selling","title":"Selling
","text":" Bases: Model
Handle the sellings.
"},{"location":"reference/counter/models/#counter.models.Selling.save","title":"save(*args, allow_negative=False, **kwargs)
","text":"allow_negative : Allow this selling to use more money than available for this user.
Source code incounter/models.py
def save(self, *args, allow_negative=False, **kwargs):\n \"\"\"allow_negative : Allow this selling to use more money than available for this user.\"\"\"\n if not self.date:\n self.date = timezone.now()\n self.full_clean()\n if not self.is_validated:\n self.customer.amount -= self.quantity * self.unit_price\n self.customer.save(allow_negative=allow_negative, is_selling=True)\n self.is_validated = True\n user = self.customer.user\n if user.was_subscribed:\n if (\n self.product\n and self.product.id == settings.SITH_PRODUCT_SUBSCRIPTION_ONE_SEMESTER\n ):\n sub = Subscription(\n member=user,\n subscription_type=\"un-semestre\",\n payment_method=\"EBOUTIC\",\n location=\"EBOUTIC\",\n )\n sub.subscription_start = Subscription.compute_start()\n sub.subscription_start = Subscription.compute_start(\n duration=settings.SITH_SUBSCRIPTIONS[sub.subscription_type][\n \"duration\"\n ]\n )\n sub.subscription_end = Subscription.compute_end(\n duration=settings.SITH_SUBSCRIPTIONS[sub.subscription_type][\n \"duration\"\n ],\n start=sub.subscription_start,\n )\n sub.save()\n elif (\n self.product\n and self.product.id == settings.SITH_PRODUCT_SUBSCRIPTION_TWO_SEMESTERS\n ):\n sub = Subscription(\n member=user,\n subscription_type=\"deux-semestres\",\n payment_method=\"EBOUTIC\",\n location=\"EBOUTIC\",\n )\n sub.subscription_start = Subscription.compute_start()\n sub.subscription_start = Subscription.compute_start(\n duration=settings.SITH_SUBSCRIPTIONS[sub.subscription_type][\n \"duration\"\n ]\n )\n sub.subscription_end = Subscription.compute_end(\n duration=settings.SITH_SUBSCRIPTIONS[sub.subscription_type][\n \"duration\"\n ],\n start=sub.subscription_start,\n )\n sub.save()\n if user.preferences.notify_on_click:\n Notification(\n user=user,\n url=reverse(\n \"core:user_account_detail\",\n kwargs={\n \"user_id\": user.id,\n \"year\": self.date.year,\n \"month\": self.date.month,\n },\n ),\n param=\"%d x %s\" % (self.quantity, self.label),\n type=\"SELLING\",\n ).save()\n super().save(*args, **kwargs)\n if hasattr(self.product, \"eticket\"):\n self.send_mail_customer()\n
"},{"location":"reference/counter/models/#counter.models.Permanency","title":"Permanency
","text":" Bases: Model
A permanency of a barman, on a counter.
This aims at storing a traceability of who was barman where and when. Mainly for dick size contest establishing the top 10 barmen of the semester.
"},{"location":"reference/counter/models/#counter.models.CashRegisterSummary","title":"CashRegisterSummary
","text":" Bases: Model
is_owned_by(user)
","text":"Method to see if that object can be edited by the given user.
Source code incounter/models.py
def is_owned_by(self, user):\n \"\"\"Method to see if that object can be edited by the given user.\"\"\"\n if user.is_anonymous:\n return False\n return user.is_in_group(pk=settings.SITH_GROUP_COUNTER_ADMIN_ID)\n
"},{"location":"reference/counter/models/#counter.models.CashRegisterSummaryItem","title":"CashRegisterSummaryItem
","text":" Bases: Model
Eticket
","text":" Bases: Model
Eticket can be linked to a product an allows PDF generation.
"},{"location":"reference/counter/models/#counter.models.Eticket.is_owned_by","title":"is_owned_by(user)
","text":"Method to see if that object can be edited by the given user.
Source code incounter/models.py
def is_owned_by(self, user):\n \"\"\"Method to see if that object can be edited by the given user.\"\"\"\n if user.is_anonymous:\n return False\n return user.is_in_group(pk=settings.SITH_GROUP_COUNTER_ADMIN_ID)\n
"},{"location":"reference/counter/models/#counter.models.StudentCard","title":"StudentCard
","text":" Bases: Model
Alternative way to connect a customer into a counter.
We are using Mifare DESFire EV1 specs since it's used for izly cards https://www.nxp.com/docs/en/application-note/AN10927.pdf UID is 7 byte long that means 14 hexa characters.
"},{"location":"reference/counter/models/#counter.models.get_start_of_semester","title":"get_start_of_semester(today=None)
","text":"Return the date of the start of the semester of the given date. If no date is given, return the start date of the current semester.
The current semester is computed as follows:
Parameters:
Name Type Description Defaulttoday
Optional[date]
the date to use to compute the semester. If None, use today's date.
None
Returns:
Type Descriptiondate
the date of the start of the semester
Source code incore/utils.py
def get_start_of_semester(today: Optional[date] = None) -> date:\n \"\"\"Return the date of the start of the semester of the given date.\n If no date is given, return the start date of the current semester.\n\n The current semester is computed as follows:\n\n - If the date is between 15/08 and 31/12 => Autumn semester.\n - If the date is between 01/01 and 15/02 => Autumn semester of the previous year.\n - If the date is between 15/02 and 15/08 => Spring semester\n\n Args:\n today: the date to use to compute the semester. If None, use today's date.\n\n Returns:\n the date of the start of the semester\n \"\"\"\n if today is None:\n today = localdate()\n\n autumn = date(today.year, *settings.SITH_SEMESTER_START_AUTUMN)\n spring = date(today.year, *settings.SITH_SEMESTER_START_SPRING)\n\n if today >= autumn: # between 15/08 (included) and 31/12 -> autumn semester\n return autumn\n if today >= spring: # between 15/02 (included) and 15/08 -> spring semester\n return spring\n # between 01/01 and 15/02 -> autumn semester of the previous year\n return autumn.replace(year=autumn.year - 1)\n
"},{"location":"reference/counter/schemas/","title":"Schemas","text":""},{"location":"reference/counter/schemas/#counter.schemas.SimpleUserSchema","title":"SimpleUserSchema
","text":" Bases: ModelSchema
A schema with the minimum amount of information to represent a user.
"},{"location":"reference/counter/schemas/#counter.schemas.Counter","title":"Counter
","text":" Bases: Model
gen_token()
","text":"Generate a new token for this counter.
Source code incounter/models.py
def gen_token(self) -> None:\n \"\"\"Generate a new token for this counter.\"\"\"\n self.token = \"\".join(\n random.choice(string.ascii_letters + string.digits) for _ in range(30)\n )\n self.save()\n
"},{"location":"reference/counter/schemas/#counter.schemas.Counter.barmen_list","title":"barmen_list()
","text":"Returns the barman list as list of User.
Source code incounter/models.py
@cached_property\ndef barmen_list(self) -> list[User]:\n \"\"\"Returns the barman list as list of User.\"\"\"\n return [\n p.user for p in self.permanencies.filter(end=None).select_related(\"user\")\n ]\n
"},{"location":"reference/counter/schemas/#counter.schemas.Counter.get_random_barman","title":"get_random_barman()
","text":"Return a random user being currently a barman.
Source code incounter/models.py
def get_random_barman(self) -> User:\n \"\"\"Return a random user being currently a barman.\"\"\"\n return random.choice(self.barmen_list)\n
"},{"location":"reference/counter/schemas/#counter.schemas.Counter.update_activity","title":"update_activity()
","text":"Update the barman activity to prevent timeout.
Source code incounter/models.py
def update_activity(self) -> None:\n \"\"\"Update the barman activity to prevent timeout.\"\"\"\n self.permanencies.filter(end=None).update(activity=timezone.now())\n
"},{"location":"reference/counter/schemas/#counter.schemas.Counter.can_refill","title":"can_refill()
","text":"Show if the counter authorize the refilling with physic money.
Source code incounter/models.py
def can_refill(self) -> bool:\n \"\"\"Show if the counter authorize the refilling with physic money.\"\"\"\n if self.type != \"BAR\":\n return False\n if self.id in SITH_COUNTER_OFFICES:\n # If the counter is either 'AE' or 'BdF', refills are authorized\n return True\n # at least one of the barmen is in the AE board\n ae = Club.objects.get(unix_name=SITH_MAIN_CLUB[\"unix_name\"])\n return any(ae.get_membership_for(barman) for barman in self.barmen_list)\n
"},{"location":"reference/counter/schemas/#counter.schemas.Counter.get_top_barmen","title":"get_top_barmen()
","text":"Return a QuerySet querying the office hours stats of all the barmen of all time of this counter, ordered by descending number of hours.
Each element of the QuerySet corresponds to a barman and has the following datacounter/models.py
def get_top_barmen(self) -> QuerySet:\n \"\"\"Return a QuerySet querying the office hours stats of all the barmen of all time\n of this counter, ordered by descending number of hours.\n\n Each element of the QuerySet corresponds to a barman and has the following data :\n - the full name (first name + last name) of the barman\n - the nickname of the barman\n - the promo of the barman\n - the total number of office hours the barman did attend\n \"\"\"\n return (\n self.permanencies.exclude(end=None)\n .annotate(\n name=Concat(F(\"user__first_name\"), Value(\" \"), F(\"user__last_name\"))\n )\n .annotate(nickname=F(\"user__nick_name\"))\n .annotate(promo=F(\"user__promo\"))\n .values(\"user\", \"name\", \"nickname\", \"promo\")\n .annotate(perm_sum=Sum(F(\"end\") - F(\"start\")))\n .exclude(perm_sum=None)\n .order_by(\"-perm_sum\")\n )\n
"},{"location":"reference/counter/schemas/#counter.schemas.Counter.get_top_customers","title":"get_top_customers(since=None)
","text":"Return a QuerySet querying the money spent by customers of this counter since the specified date, ordered by descending amount of money spent.
Each element of the QuerySet corresponds to a customer and has the following data :
Parameters:
Name Type Description Defaultsince
datetime | date | None
timestamp from which to perform the calculation
None
Source code in counter/models.py
def get_top_customers(self, since: datetime | date | None = None) -> QuerySet:\n \"\"\"Return a QuerySet querying the money spent by customers of this counter\n since the specified date, ordered by descending amount of money spent.\n\n Each element of the QuerySet corresponds to a customer and has the following data :\n\n - the full name (first name + last name) of the customer\n - the nickname of the customer\n - the amount of money spent by the customer\n\n Args:\n since: timestamp from which to perform the calculation\n \"\"\"\n if since is None:\n since = get_start_of_semester()\n if isinstance(since, date):\n since = datetime(since.year, since.month, since.day, tzinfo=tz.utc)\n return (\n self.sellings.filter(date__gte=since)\n .annotate(\n name=Concat(\n F(\"customer__user__first_name\"),\n Value(\" \"),\n F(\"customer__user__last_name\"),\n )\n )\n .annotate(nickname=F(\"customer__user__nick_name\"))\n .annotate(promo=F(\"customer__user__promo\"))\n .annotate(user=F(\"customer__user\"))\n .values(\"user\", \"promo\", \"name\", \"nickname\")\n .annotate(\n selling_sum=Sum(\n F(\"unit_price\") * F(\"quantity\"), output_field=CurrencyField()\n )\n )\n .filter(selling_sum__gt=0)\n .order_by(\"-selling_sum\")\n )\n
"},{"location":"reference/counter/schemas/#counter.schemas.Counter.get_total_sales","title":"get_total_sales(since=None)
","text":"Compute and return the total turnover of this counter since the given date.
By default, the date is the start of the current semester.
Parameters:
Name Type Description Defaultsince
datetime | date | None
timestamp from which to perform the calculation
None
Returns:
Type DescriptionCurrencyField
Total revenue earned at this counter.
Source code incounter/models.py
def get_total_sales(self, since: datetime | date | None = None) -> CurrencyField:\n \"\"\"Compute and return the total turnover of this counter since the given date.\n\n By default, the date is the start of the current semester.\n\n Args:\n since: timestamp from which to perform the calculation\n\n Returns:\n Total revenue earned at this counter.\n \"\"\"\n if since is None:\n since = get_start_of_semester()\n if isinstance(since, date):\n since = datetime(since.year, since.month, since.day, tzinfo=tz.utc)\n return self.sellings.filter(date__gte=since).aggregate(\n total=Sum(\n F(\"quantity\") * F(\"unit_price\"),\n default=0,\n output_field=CurrencyField(),\n )\n )[\"total\"]\n
"},{"location":"reference/counter/schemas/#counter.schemas.Product","title":"Product
","text":" Bases: Model
A product, with all its related information.
"},{"location":"reference/counter/schemas/#counter.schemas.Product.is_owned_by","title":"is_owned_by(user)
","text":"Method to see if that object can be edited by the given user.
Source code incounter/models.py
def is_owned_by(self, user):\n \"\"\"Method to see if that object can be edited by the given user.\"\"\"\n if user.is_anonymous:\n return False\n return user.is_in_group(\n pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID\n ) or user.is_in_group(pk=settings.SITH_GROUP_COUNTER_ADMIN_ID)\n
"},{"location":"reference/counter/schemas/#counter.schemas.Product.can_be_sold_to","title":"can_be_sold_to(user)
","text":"Check if whether the user given in parameter has the right to buy this product or not.
This must be not confused with the Customer.can_buy() method as the present method returns an information about the relation between a User and a Product, whereas the other tells something about a Customer (and not a user, they are not the same model).
Returns:
Type Descriptionbool
True if the user can buy this product else False
WarningThis 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 :
user = User.objects.get(username=\"foobar\")\nproducts = [\n p\n for p in Product.objects.prefetch_related(\"buying_groups\")\n if p.can_be_sold_to(user)\n]\n
Source code in counter/models.py
def can_be_sold_to(self, user: User) -> bool:\n \"\"\"Check if whether the user given in parameter has the right to buy\n this product or not.\n\n This must be not confused with the Customer.can_buy()\n method as the present method returns an information\n about the relation between a User and a Product,\n whereas the other tells something about a Customer\n (and not a user, they are not the same model).\n\n Returns:\n True if the user can buy this product else False\n\n Warning:\n This performs a db query, thus you can quickly have\n a N+1 queries problem if you call it in a loop.\n Hopefully, you can avoid that if you prefetch the buying_groups :\n\n ```python\n user = User.objects.get(username=\"foobar\")\n products = [\n p\n for p in Product.objects.prefetch_related(\"buying_groups\")\n if p.can_be_sold_to(user)\n ]\n ```\n \"\"\"\n buying_groups = list(self.buying_groups.all())\n if not buying_groups:\n return True\n return any(user.is_in_group(pk=group.id) for group in buying_groups)\n
"},{"location":"reference/counter/schemas/#counter.schemas.CounterSchema","title":"CounterSchema
","text":" Bases: ModelSchema
CounterFilterSchema
","text":" Bases: FilterSchema
SimplifiedCounterSchema
","text":" Bases: ModelSchema
ProductSchema
","text":" Bases: ModelSchema
CurrencyField(*args, **kwargs)
","text":" Bases: DecimalField
Custom database field used for currency.
Source code inaccounting/models.py
def __init__(self, *args, **kwargs):\n kwargs[\"max_digits\"] = 12\n kwargs[\"decimal_places\"] = 2\n super().__init__(*args, **kwargs)\n
"},{"location":"reference/counter/views/#counter.views.CanEditMixin","title":"CanEditMixin
","text":" Bases: GenericContentPermissionMixinBuilder
Ensure the user has permission to edit this view's object.
Raises:
Type DescriptionPermissionDenied
if the user cannot edit this view's object.
"},{"location":"reference/counter/views/#counter.views.CanViewMixin","title":"CanViewMixin
","text":" Bases: GenericContentPermissionMixinBuilder
Ensure the user has permission to view this view's object.
Raises:
Type DescriptionPermissionDenied
if the user cannot edit this view's object.
"},{"location":"reference/counter/views/#counter.views.TabedViewMixin","title":"TabedViewMixin
","text":" Bases: View
Basic functions for displaying tabs in the template.
"},{"location":"reference/counter/views/#counter.views.CashSummaryFormBase","title":"CashSummaryFormBase
","text":" Bases: Form
CounterEditForm
","text":" Bases: ModelForm
EticketForm
","text":" Bases: ModelForm
GetUserForm
","text":" Bases: Form
The Form class aims at providing a valid user_id field in its cleaned data, in order to pass it to some view, reverse function, or any other use.
The Form implements a nice JS widget allowing the user to type a customer account id, or search the database with some nickname, first name, or last name (TODO)
"},{"location":"reference/counter/views/#counter.views.NFCCardForm","title":"NFCCardForm
","text":" Bases: Form
ProductEditForm(*args, **kwargs)
","text":" Bases: ModelForm
counter/forms.py
def __init__(self, *args, **kwargs):\n super().__init__(*args, **kwargs)\n if self.instance.id:\n self.fields[\"counters\"].initial = self.instance.counters.all()\n
"},{"location":"reference/counter/views/#counter.views.RefillForm","title":"RefillForm
","text":" Bases: ModelForm
StudentCardForm
","text":" Bases: ModelForm
Form for adding student cards Only used for user profile since CounterClick is to complicated.
"},{"location":"reference/counter/views/#counter.views.CashRegisterSummary","title":"CashRegisterSummary
","text":" Bases: Model
is_owned_by(user)
","text":"Method to see if that object can be edited by the given user.
Source code incounter/models.py
def is_owned_by(self, user):\n \"\"\"Method to see if that object can be edited by the given user.\"\"\"\n if user.is_anonymous:\n return False\n return user.is_in_group(pk=settings.SITH_GROUP_COUNTER_ADMIN_ID)\n
"},{"location":"reference/counter/views/#counter.views.CashRegisterSummaryItem","title":"CashRegisterSummaryItem
","text":" Bases: Model
Counter
","text":" Bases: Model
gen_token()
","text":"Generate a new token for this counter.
Source code incounter/models.py
def gen_token(self) -> None:\n \"\"\"Generate a new token for this counter.\"\"\"\n self.token = \"\".join(\n random.choice(string.ascii_letters + string.digits) for _ in range(30)\n )\n self.save()\n
"},{"location":"reference/counter/views/#counter.views.Counter.barmen_list","title":"barmen_list()
","text":"Returns the barman list as list of User.
Source code incounter/models.py
@cached_property\ndef barmen_list(self) -> list[User]:\n \"\"\"Returns the barman list as list of User.\"\"\"\n return [\n p.user for p in self.permanencies.filter(end=None).select_related(\"user\")\n ]\n
"},{"location":"reference/counter/views/#counter.views.Counter.get_random_barman","title":"get_random_barman()
","text":"Return a random user being currently a barman.
Source code incounter/models.py
def get_random_barman(self) -> User:\n \"\"\"Return a random user being currently a barman.\"\"\"\n return random.choice(self.barmen_list)\n
"},{"location":"reference/counter/views/#counter.views.Counter.update_activity","title":"update_activity()
","text":"Update the barman activity to prevent timeout.
Source code incounter/models.py
def update_activity(self) -> None:\n \"\"\"Update the barman activity to prevent timeout.\"\"\"\n self.permanencies.filter(end=None).update(activity=timezone.now())\n
"},{"location":"reference/counter/views/#counter.views.Counter.can_refill","title":"can_refill()
","text":"Show if the counter authorize the refilling with physic money.
Source code incounter/models.py
def can_refill(self) -> bool:\n \"\"\"Show if the counter authorize the refilling with physic money.\"\"\"\n if self.type != \"BAR\":\n return False\n if self.id in SITH_COUNTER_OFFICES:\n # If the counter is either 'AE' or 'BdF', refills are authorized\n return True\n # at least one of the barmen is in the AE board\n ae = Club.objects.get(unix_name=SITH_MAIN_CLUB[\"unix_name\"])\n return any(ae.get_membership_for(barman) for barman in self.barmen_list)\n
"},{"location":"reference/counter/views/#counter.views.Counter.get_top_barmen","title":"get_top_barmen()
","text":"Return a QuerySet querying the office hours stats of all the barmen of all time of this counter, ordered by descending number of hours.
Each element of the QuerySet corresponds to a barman and has the following datacounter/models.py
def get_top_barmen(self) -> QuerySet:\n \"\"\"Return a QuerySet querying the office hours stats of all the barmen of all time\n of this counter, ordered by descending number of hours.\n\n Each element of the QuerySet corresponds to a barman and has the following data :\n - the full name (first name + last name) of the barman\n - the nickname of the barman\n - the promo of the barman\n - the total number of office hours the barman did attend\n \"\"\"\n return (\n self.permanencies.exclude(end=None)\n .annotate(\n name=Concat(F(\"user__first_name\"), Value(\" \"), F(\"user__last_name\"))\n )\n .annotate(nickname=F(\"user__nick_name\"))\n .annotate(promo=F(\"user__promo\"))\n .values(\"user\", \"name\", \"nickname\", \"promo\")\n .annotate(perm_sum=Sum(F(\"end\") - F(\"start\")))\n .exclude(perm_sum=None)\n .order_by(\"-perm_sum\")\n )\n
"},{"location":"reference/counter/views/#counter.views.Counter.get_top_customers","title":"get_top_customers(since=None)
","text":"Return a QuerySet querying the money spent by customers of this counter since the specified date, ordered by descending amount of money spent.
Each element of the QuerySet corresponds to a customer and has the following data :
Parameters:
Name Type Description Defaultsince
datetime | date | None
timestamp from which to perform the calculation
None
Source code in counter/models.py
def get_top_customers(self, since: datetime | date | None = None) -> QuerySet:\n \"\"\"Return a QuerySet querying the money spent by customers of this counter\n since the specified date, ordered by descending amount of money spent.\n\n Each element of the QuerySet corresponds to a customer and has the following data :\n\n - the full name (first name + last name) of the customer\n - the nickname of the customer\n - the amount of money spent by the customer\n\n Args:\n since: timestamp from which to perform the calculation\n \"\"\"\n if since is None:\n since = get_start_of_semester()\n if isinstance(since, date):\n since = datetime(since.year, since.month, since.day, tzinfo=tz.utc)\n return (\n self.sellings.filter(date__gte=since)\n .annotate(\n name=Concat(\n F(\"customer__user__first_name\"),\n Value(\" \"),\n F(\"customer__user__last_name\"),\n )\n )\n .annotate(nickname=F(\"customer__user__nick_name\"))\n .annotate(promo=F(\"customer__user__promo\"))\n .annotate(user=F(\"customer__user\"))\n .values(\"user\", \"promo\", \"name\", \"nickname\")\n .annotate(\n selling_sum=Sum(\n F(\"unit_price\") * F(\"quantity\"), output_field=CurrencyField()\n )\n )\n .filter(selling_sum__gt=0)\n .order_by(\"-selling_sum\")\n )\n
"},{"location":"reference/counter/views/#counter.views.Counter.get_total_sales","title":"get_total_sales(since=None)
","text":"Compute and return the total turnover of this counter since the given date.
By default, the date is the start of the current semester.
Parameters:
Name Type Description Defaultsince
datetime | date | None
timestamp from which to perform the calculation
None
Returns:
Type DescriptionCurrencyField
Total revenue earned at this counter.
Source code incounter/models.py
def get_total_sales(self, since: datetime | date | None = None) -> CurrencyField:\n \"\"\"Compute and return the total turnover of this counter since the given date.\n\n By default, the date is the start of the current semester.\n\n Args:\n since: timestamp from which to perform the calculation\n\n Returns:\n Total revenue earned at this counter.\n \"\"\"\n if since is None:\n since = get_start_of_semester()\n if isinstance(since, date):\n since = datetime(since.year, since.month, since.day, tzinfo=tz.utc)\n return self.sellings.filter(date__gte=since).aggregate(\n total=Sum(\n F(\"quantity\") * F(\"unit_price\"),\n default=0,\n output_field=CurrencyField(),\n )\n )[\"total\"]\n
"},{"location":"reference/counter/views/#counter.views.Customer","title":"Customer
","text":" Bases: Model
Customer data of a User.
It adds some basic customers' information, such as the account ID, and is used by other accounting classes as reference to the customer, rather than using User.
"},{"location":"reference/counter/views/#counter.views.Customer.can_buy","title":"can_buy: bool
property
","text":"Check if whether this customer has the right to purchase any item.
This must be not confused with the Product.can_be_sold_to(user) method as the present method returns an information about a customer whereas the other tells something about the relation between a User (not a Customer, don't mix them) and a Product.
"},{"location":"reference/counter/views/#counter.views.Customer.save","title":"save(*args, allow_negative=False, is_selling=False, **kwargs)
","text":"is_selling : tell if the current action is a selling allow_negative : ignored if not a selling. Allow a selling to put the account in negative Those two parameters avoid blocking the save method of a customer if his account is negative.
Source code incounter/models.py
def save(self, *args, allow_negative=False, is_selling=False, **kwargs):\n \"\"\"is_selling : tell if the current action is a selling\n allow_negative : ignored if not a selling. Allow a selling to put the account in negative\n Those two parameters avoid blocking the save method of a customer if his account is negative.\n \"\"\"\n if self.amount < 0 and (is_selling and not allow_negative):\n raise ValidationError(_(\"Not enough money\"))\n super().save(*args, **kwargs)\n
"},{"location":"reference/counter/views/#counter.views.Customer.get_or_create","title":"get_or_create(user)
classmethod
","text":"Work in pretty much the same way as the usual get_or_create method, but with the default field replaced by some under the hood.
If the user has an account, return it as is. Else create a new account with no money on it and a new unique account id
Example : ::
user = User.objects.get(pk=1)\naccount, created = Customer.get_or_create(user)\nif created:\n print(f\"created a new account with id {account.id}\")\nelse:\n print(f\"user has already an account, with {account.id} \u20ac on it\"\n
Source code in counter/models.py
@classmethod\ndef get_or_create(cls, user: User) -> Tuple[Customer, bool]:\n \"\"\"Work in pretty much the same way as the usual get_or_create method,\n but with the default field replaced by some under the hood.\n\n If the user has an account, return it as is.\n Else create a new account with no money on it and a new unique account id\n\n Example : ::\n\n user = User.objects.get(pk=1)\n account, created = Customer.get_or_create(user)\n if created:\n print(f\"created a new account with id {account.id}\")\n else:\n print(f\"user has already an account, with {account.id} \u20ac on it\"\n \"\"\"\n if hasattr(user, \"customer\"):\n return user.customer, False\n\n # account_id are always a number with a letter appended\n account_id = (\n Customer.objects.order_by(Length(\"account_id\"), \"account_id\")\n .values(\"account_id\")\n .last()\n )\n if account_id is None:\n # legacy from the old site\n account = cls.objects.create(user=user, account_id=\"1504a\")\n return account, True\n\n account_id = account_id[\"account_id\"]\n account_num = int(account_id[:-1])\n while Customer.objects.filter(account_id=account_id).exists():\n # when entering the first iteration, we are using an already existing account id\n # so the loop should always execute at least one time\n account_num += 1\n account_id = f\"{account_num}{random.choice(string.ascii_lowercase)}\"\n\n account = cls.objects.create(user=user, account_id=account_id)\n return account, True\n
"},{"location":"reference/counter/views/#counter.views.Eticket","title":"Eticket
","text":" Bases: Model
Eticket can be linked to a product an allows PDF generation.
"},{"location":"reference/counter/views/#counter.views.Eticket.is_owned_by","title":"is_owned_by(user)
","text":"Method to see if that object can be edited by the given user.
Source code incounter/models.py
def is_owned_by(self, user):\n \"\"\"Method to see if that object can be edited by the given user.\"\"\"\n if user.is_anonymous:\n return False\n return user.is_in_group(pk=settings.SITH_GROUP_COUNTER_ADMIN_ID)\n
"},{"location":"reference/counter/views/#counter.views.Permanency","title":"Permanency
","text":" Bases: Model
A permanency of a barman, on a counter.
This aims at storing a traceability of who was barman where and when. Mainly for dick size contest establishing the top 10 barmen of the semester.
"},{"location":"reference/counter/views/#counter.views.Product","title":"Product
","text":" Bases: Model
A product, with all its related information.
"},{"location":"reference/counter/views/#counter.views.Product.is_owned_by","title":"is_owned_by(user)
","text":"Method to see if that object can be edited by the given user.
Source code incounter/models.py
def is_owned_by(self, user):\n \"\"\"Method to see if that object can be edited by the given user.\"\"\"\n if user.is_anonymous:\n return False\n return user.is_in_group(\n pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID\n ) or user.is_in_group(pk=settings.SITH_GROUP_COUNTER_ADMIN_ID)\n
"},{"location":"reference/counter/views/#counter.views.Product.can_be_sold_to","title":"can_be_sold_to(user)
","text":"Check if whether the user given in parameter has the right to buy this product or not.
This must be not confused with the Customer.can_buy() method as the present method returns an information about the relation between a User and a Product, whereas the other tells something about a Customer (and not a user, they are not the same model).
Returns:
Type Descriptionbool
True if the user can buy this product else False
WarningThis 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 :
user = User.objects.get(username=\"foobar\")\nproducts = [\n p\n for p in Product.objects.prefetch_related(\"buying_groups\")\n if p.can_be_sold_to(user)\n]\n
Source code in counter/models.py
def can_be_sold_to(self, user: User) -> bool:\n \"\"\"Check if whether the user given in parameter has the right to buy\n this product or not.\n\n This must be not confused with the Customer.can_buy()\n method as the present method returns an information\n about the relation between a User and a Product,\n whereas the other tells something about a Customer\n (and not a user, they are not the same model).\n\n Returns:\n True if the user can buy this product else False\n\n Warning:\n This performs a db query, thus you can quickly have\n a N+1 queries problem if you call it in a loop.\n Hopefully, you can avoid that if you prefetch the buying_groups :\n\n ```python\n user = User.objects.get(username=\"foobar\")\n products = [\n p\n for p in Product.objects.prefetch_related(\"buying_groups\")\n if p.can_be_sold_to(user)\n ]\n ```\n \"\"\"\n buying_groups = list(self.buying_groups.all())\n if not buying_groups:\n return True\n return any(user.is_in_group(pk=group.id) for group in buying_groups)\n
"},{"location":"reference/counter/views/#counter.views.ProductType","title":"ProductType
","text":" Bases: Model
A product type.
Useful only for categorizing.
"},{"location":"reference/counter/views/#counter.views.ProductType.is_owned_by","title":"is_owned_by(user)
","text":"Method to see if that object can be edited by the given user.
Source code incounter/models.py
def is_owned_by(self, user):\n \"\"\"Method to see if that object can be edited by the given user.\"\"\"\n if user.is_anonymous:\n return False\n return user.is_in_group(pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID)\n
"},{"location":"reference/counter/views/#counter.views.Refilling","title":"Refilling
","text":" Bases: Model
Handle the refilling.
"},{"location":"reference/counter/views/#counter.views.Selling","title":"Selling
","text":" Bases: Model
Handle the sellings.
"},{"location":"reference/counter/views/#counter.views.Selling.save","title":"save(*args, allow_negative=False, **kwargs)
","text":"allow_negative : Allow this selling to use more money than available for this user.
Source code incounter/models.py
def save(self, *args, allow_negative=False, **kwargs):\n \"\"\"allow_negative : Allow this selling to use more money than available for this user.\"\"\"\n if not self.date:\n self.date = timezone.now()\n self.full_clean()\n if not self.is_validated:\n self.customer.amount -= self.quantity * self.unit_price\n self.customer.save(allow_negative=allow_negative, is_selling=True)\n self.is_validated = True\n user = self.customer.user\n if user.was_subscribed:\n if (\n self.product\n and self.product.id == settings.SITH_PRODUCT_SUBSCRIPTION_ONE_SEMESTER\n ):\n sub = Subscription(\n member=user,\n subscription_type=\"un-semestre\",\n payment_method=\"EBOUTIC\",\n location=\"EBOUTIC\",\n )\n sub.subscription_start = Subscription.compute_start()\n sub.subscription_start = Subscription.compute_start(\n duration=settings.SITH_SUBSCRIPTIONS[sub.subscription_type][\n \"duration\"\n ]\n )\n sub.subscription_end = Subscription.compute_end(\n duration=settings.SITH_SUBSCRIPTIONS[sub.subscription_type][\n \"duration\"\n ],\n start=sub.subscription_start,\n )\n sub.save()\n elif (\n self.product\n and self.product.id == settings.SITH_PRODUCT_SUBSCRIPTION_TWO_SEMESTERS\n ):\n sub = Subscription(\n member=user,\n subscription_type=\"deux-semestres\",\n payment_method=\"EBOUTIC\",\n location=\"EBOUTIC\",\n )\n sub.subscription_start = Subscription.compute_start()\n sub.subscription_start = Subscription.compute_start(\n duration=settings.SITH_SUBSCRIPTIONS[sub.subscription_type][\n \"duration\"\n ]\n )\n sub.subscription_end = Subscription.compute_end(\n duration=settings.SITH_SUBSCRIPTIONS[sub.subscription_type][\n \"duration\"\n ],\n start=sub.subscription_start,\n )\n sub.save()\n if user.preferences.notify_on_click:\n Notification(\n user=user,\n url=reverse(\n \"core:user_account_detail\",\n kwargs={\n \"user_id\": user.id,\n \"year\": self.date.year,\n \"month\": self.date.month,\n },\n ),\n param=\"%d x %s\" % (self.quantity, self.label),\n type=\"SELLING\",\n ).save()\n super().save(*args, **kwargs)\n if hasattr(self.product, \"eticket\"):\n self.send_mail_customer()\n
"},{"location":"reference/counter/views/#counter.views.StudentCard","title":"StudentCard
","text":" Bases: Model
Alternative way to connect a customer into a counter.
We are using Mifare DESFire EV1 specs since it's used for izly cards https://www.nxp.com/docs/en/application-note/AN10927.pdf UID is 7 byte long that means 14 hexa characters.
"},{"location":"reference/counter/views/#counter.views.User","title":"User
","text":" Bases: AbstractBaseUser
Defines the base user class, useable in every app.
This is almost the same as the auth module AbstractUser since it inherits from it, but some fields are required, and the username is generated automatically with the name of the user (see generate_username()).
Added field: nick_name, date_of_birth Required fields: email, first_name, last_name, date_of_birth
"},{"location":"reference/counter/views/#counter.views.User.cached_groups","title":"cached_groups: list[Group]
property
","text":"Get the list of groups this user is in.
The result is cached for the default duration (should be 5 minutes)
Returns: A list of all the groups this user is in.
"},{"location":"reference/counter/views/#counter.views.User.is_in_group","title":"is_in_group(*, pk=None, name=None)
","text":"Check if this user is in the given group. Either a group id or a group name must be provided. If both are passed, only the id will be considered.
The group will be fetched using the given parameter. If no group is found, return False. If a group is found, check if this user is in the latter.
Returns:
Type Descriptionbool
True if the user is the group, else False
Source code incore/models.py
def is_in_group(self, *, pk: int | None = None, name: str | None = None) -> bool:\n \"\"\"Check if this user is in the given group.\n Either a group id or a group name must be provided.\n If both are passed, only the id will be considered.\n\n The group will be fetched using the given parameter.\n If no group is found, return False.\n If a group is found, check if this user is in the latter.\n\n Returns:\n True if the user is the group, else False\n \"\"\"\n if pk is not None:\n group: Optional[Group] = get_group(pk=pk)\n elif name is not None:\n group: Optional[Group] = get_group(name=name)\n else:\n raise ValueError(\"You must either provide the id or the name of the group\")\n if group is None:\n return False\n if group.id == settings.SITH_GROUP_PUBLIC_ID:\n return True\n if group.id == settings.SITH_GROUP_SUBSCRIBERS_ID:\n return self.is_subscribed\n if group.id == settings.SITH_GROUP_OLD_SUBSCRIBERS_ID:\n return self.was_subscribed\n if group.id == settings.SITH_GROUP_ROOT_ID:\n return self.is_root\n if group.is_meta:\n # check if this group is associated with a club\n group.__class__ = MetaGroup\n club = group.associated_club\n if club is None:\n return False\n membership = club.get_membership_for(self)\n if membership is None:\n return False\n if group.name.endswith(settings.SITH_MEMBER_SUFFIX):\n return True\n return membership.role > settings.SITH_MAXIMUM_FREE_ROLE\n return group in self.cached_groups\n
"},{"location":"reference/counter/views/#counter.views.User.age","title":"age()
","text":"Return the age this user has the day the method is called. If the user has not filled his age, return 0.
Source code incore/models.py
@cached_property\ndef age(self) -> int:\n \"\"\"Return the age this user has the day the method is called.\n If the user has not filled his age, return 0.\n \"\"\"\n if self.date_of_birth is None:\n return 0\n today = timezone.now()\n age = today.year - self.date_of_birth.year\n # remove a year if this year's birthday is yet to come\n age -= (today.month, today.day) < (\n self.date_of_birth.month,\n self.date_of_birth.day,\n )\n return age\n
"},{"location":"reference/counter/views/#counter.views.User.get_full_name","title":"get_full_name()
","text":"Returns the first_name plus the last_name, with a space in between.
Source code incore/models.py
def get_full_name(self):\n \"\"\"Returns the first_name plus the last_name, with a space in between.\"\"\"\n full_name = \"%s %s\" % (self.first_name, self.last_name)\n return full_name.strip()\n
"},{"location":"reference/counter/views/#counter.views.User.get_short_name","title":"get_short_name()
","text":"Returns the short name for the user.
Source code incore/models.py
def get_short_name(self):\n \"\"\"Returns the short name for the user.\"\"\"\n if self.nick_name:\n return self.nick_name\n return self.first_name + \" \" + self.last_name\n
"},{"location":"reference/counter/views/#counter.views.User.get_display_name","title":"get_display_name()
","text":"Returns the display name of the user.
A nickname if possible, otherwise, the full name.
Source code incore/models.py
def get_display_name(self) -> str:\n \"\"\"Returns the display name of the user.\n\n A nickname if possible, otherwise, the full name.\n \"\"\"\n if self.nick_name:\n return \"%s (%s)\" % (self.get_full_name(), self.nick_name)\n return self.get_full_name()\n
"},{"location":"reference/counter/views/#counter.views.User.get_age","title":"get_age()
","text":"Returns the age.
Source code incore/models.py
def get_age(self):\n \"\"\"Returns the age.\"\"\"\n today = timezone.now()\n born = self.date_of_birth\n return (\n today.year - born.year - ((today.month, today.day) < (born.month, born.day))\n )\n
"},{"location":"reference/counter/views/#counter.views.User.get_family","title":"get_family(godfathers_depth=4, godchildren_depth=4)
","text":"Get the family of the user, with the given depth.
Parameters:
Name Type Description Defaultgodfathers_depth
NonNegativeInt
The number of generations of godfathers to fetch
4
godchildren_depth
NonNegativeInt
The number of generations of godchildren to fetch
4
Returns:
Type Descriptionset[through]
A list of family relationships in this user's family
Source code incore/models.py
def get_family(\n self,\n godfathers_depth: NonNegativeInt = 4,\n godchildren_depth: NonNegativeInt = 4,\n) -> set[User.godfathers.through]:\n \"\"\"Get the family of the user, with the given depth.\n\n Args:\n godfathers_depth: The number of generations of godfathers to fetch\n godchildren_depth: The number of generations of godchildren to fetch\n\n Returns:\n A list of family relationships in this user's family\n \"\"\"\n res = []\n for depth, key, reverse_key in [\n (godfathers_depth, \"from_user_id\", \"to_user_id\"),\n (godchildren_depth, \"to_user_id\", \"from_user_id\"),\n ]:\n if depth == 0:\n continue\n links = list(User.godfathers.through.objects.filter(**{key: self.id}))\n res.extend(links)\n for _ in range(1, depth): # noqa: F402 we don't care about gettext here\n ids = [getattr(c, reverse_key) for c in links]\n links = list(\n User.godfathers.through.objects.filter(\n **{f\"{key}__in\": ids}\n ).exclude(id__in=[r.id for r in res])\n )\n if not links:\n break\n res.extend(links)\n return set(res)\n
"},{"location":"reference/counter/views/#counter.views.User.email_user","title":"email_user(subject, message, from_email=None, **kwargs)
","text":"Sends an email to this User.
Source code incore/models.py
def email_user(self, subject, message, from_email=None, **kwargs):\n \"\"\"Sends an email to this User.\"\"\"\n if from_email is None:\n from_email = settings.DEFAULT_FROM_EMAIL\n send_mail(subject, message, from_email, [self.email], **kwargs)\n
"},{"location":"reference/counter/views/#counter.views.User.generate_username","title":"generate_username()
","text":"Generates a unique username based on the first and last names.
For example: Guy Carlier gives gcarlier, and gcarlier1 if the first one exists.
Returns:
Type Descriptionstr
The generated username.
Source code incore/models.py
def generate_username(self) -> str:\n \"\"\"Generates a unique username based on the first and last names.\n\n For example: Guy Carlier gives gcarlier, and gcarlier1 if the first one exists.\n\n Returns:\n The generated username.\n \"\"\"\n\n def remove_accents(data):\n return \"\".join(\n x\n for x in unicodedata.normalize(\"NFKD\", data)\n if unicodedata.category(x)[0] == \"L\"\n ).lower()\n\n user_name = (\n remove_accents(self.first_name[0] + self.last_name)\n .encode(\"ascii\", \"ignore\")\n .decode(\"utf-8\")\n )\n un_set = [u.username for u in User.objects.all()]\n if user_name in un_set:\n i = 1\n while user_name + str(i) in un_set:\n i += 1\n user_name += str(i)\n self.username = user_name\n return user_name\n
"},{"location":"reference/counter/views/#counter.views.User.is_owner","title":"is_owner(obj)
","text":"Determine if the object is owned by the user.
Source code incore/models.py
def is_owner(self, obj):\n \"\"\"Determine if the object is owned by the user.\"\"\"\n if hasattr(obj, \"is_owned_by\") and obj.is_owned_by(self):\n return True\n if hasattr(obj, \"owner_group\") and self.is_in_group(pk=obj.owner_group.id):\n return True\n return self.is_root\n
"},{"location":"reference/counter/views/#counter.views.User.can_edit","title":"can_edit(obj)
","text":"Determine if the object can be edited by the user.
Source code incore/models.py
def can_edit(self, obj):\n \"\"\"Determine if the object can be edited by the user.\"\"\"\n if hasattr(obj, \"can_be_edited_by\") and obj.can_be_edited_by(self):\n return True\n if hasattr(obj, \"edit_groups\"):\n for pk in obj.edit_groups.values_list(\"pk\", flat=True):\n if self.is_in_group(pk=pk):\n return True\n if isinstance(obj, User) and obj == self:\n return True\n return self.is_owner(obj)\n
"},{"location":"reference/counter/views/#counter.views.User.can_view","title":"can_view(obj)
","text":"Determine if the object can be viewed by the user.
Source code incore/models.py
def can_view(self, obj):\n \"\"\"Determine if the object can be viewed by the user.\"\"\"\n if hasattr(obj, \"can_be_viewed_by\") and obj.can_be_viewed_by(self):\n return True\n if hasattr(obj, \"view_groups\"):\n for pk in obj.view_groups.values_list(\"pk\", flat=True):\n if self.is_in_group(pk=pk):\n return True\n return self.can_edit(obj)\n
"},{"location":"reference/counter/views/#counter.views.User.clubs_with_rights","title":"clubs_with_rights()
","text":"The list of clubs where the user has rights
Source code incore/models.py
@cached_property\ndef clubs_with_rights(self) -> list[Club]:\n \"\"\"The list of clubs where the user has rights\"\"\"\n memberships = self.memberships.ongoing().board().select_related(\"club\")\n return [m.club for m in memberships]\n
"},{"location":"reference/counter/views/#counter.views.CounterAdminMixin","title":"CounterAdminMixin
","text":" Bases: View
Protect counter admin section.
"},{"location":"reference/counter/views/#counter.views.StudentCardDeleteView","title":"StudentCardDeleteView
","text":" Bases: DeleteView
, CanEditMixin
View used to delete a card from a user.
"},{"location":"reference/counter/views/#counter.views.CounterTabsMixin","title":"CounterTabsMixin
","text":" Bases: TabedViewMixin
CounterMain
","text":" Bases: CounterTabsMixin
, CanViewMixin
, DetailView
, ProcessFormView
, FormMixin
The public (barman) view.
"},{"location":"reference/counter/views/#counter.views.CounterMain.get_context_data","title":"get_context_data(**kwargs)
","text":"We handle here the login form for the barman.
Source code incounter/views.py
def get_context_data(self, **kwargs):\n \"\"\"We handle here the login form for the barman.\"\"\"\n if self.request.method == \"POST\":\n self.object = self.get_object()\n self.object.update_activity()\n kwargs = super().get_context_data(**kwargs)\n kwargs[\"login_form\"] = LoginForm()\n kwargs[\"login_form\"].fields[\"username\"].widget.attrs[\"autofocus\"] = True\n kwargs[\n \"login_form\"\n ].cleaned_data = {} # add_error fails if there are no cleaned_data\n if \"credentials\" in self.request.GET:\n kwargs[\"login_form\"].add_error(None, _(\"Bad credentials\"))\n if \"sellers\" in self.request.GET:\n kwargs[\"login_form\"].add_error(None, _(\"User is not barman\"))\n kwargs[\"form\"] = self.get_form()\n kwargs[\"form\"].cleaned_data = {} # same as above\n if \"bad_location\" in self.request.GET:\n kwargs[\"form\"].add_error(\n None, _(\"Bad location, someone is already logged in somewhere else\")\n )\n if self.object.type == \"BAR\":\n kwargs[\"barmen\"] = self.object.barmen_list\n elif self.request.user.is_authenticated:\n kwargs[\"barmen\"] = [self.request.user]\n if \"last_basket\" in self.request.session:\n kwargs[\"last_basket\"] = self.request.session.pop(\"last_basket\")\n kwargs[\"last_customer\"] = self.request.session.pop(\"last_customer\")\n kwargs[\"last_total\"] = self.request.session.pop(\"last_total\")\n kwargs[\"new_customer_amount\"] = self.request.session.pop(\n \"new_customer_amount\"\n )\n return kwargs\n
"},{"location":"reference/counter/views/#counter.views.CounterMain.form_valid","title":"form_valid(form)
","text":"We handle here the redirection, passing the user id of the asked customer.
Source code incounter/views.py
def form_valid(self, form):\n \"\"\"We handle here the redirection, passing the user id of the asked customer.\"\"\"\n self.kwargs[\"user_id\"] = form.cleaned_data[\"user_id\"]\n return super().form_valid(form)\n
"},{"location":"reference/counter/views/#counter.views.CounterClick","title":"CounterClick
","text":" Bases: CounterTabsMixin
, CanViewMixin
, DetailView
The click view This is a detail view not to have to worry about loading the counter Everything is made by hand in the post method.
"},{"location":"reference/counter/views/#counter.views.CounterClick.get","title":"get(request, *args, **kwargs)
","text":"Simple get view.
Source code incounter/views.py
def get(self, request, *args, **kwargs):\n \"\"\"Simple get view.\"\"\"\n if \"basket\" not in request.session: # Init the basket session entry\n request.session[\"basket\"] = {}\n request.session[\"basket_total\"] = 0\n request.session[\"not_enough\"] = False # Reset every variable\n request.session[\"too_young\"] = False\n request.session[\"not_allowed\"] = False\n request.session[\"no_age\"] = False\n self.refill_form = None\n ret = super().get(request, *args, **kwargs)\n if (self.object.type != \"BAR\" and not request.user.is_authenticated) or (\n self.object.type == \"BAR\" and len(self.object.barmen_list) == 0\n ): # Check that at least one barman is logged in\n ret = self.cancel(request) # Otherwise, go to main view\n return ret\n
"},{"location":"reference/counter/views/#counter.views.CounterClick.post","title":"post(request, *args, **kwargs)
","text":"Handle the many possibilities of the post request.
Source code incounter/views.py
def post(self, request, *args, **kwargs):\n \"\"\"Handle the many possibilities of the post request.\"\"\"\n self.object = self.get_object()\n self.refill_form = None\n if (self.object.type != \"BAR\" and not request.user.is_authenticated) or (\n self.object.type == \"BAR\" and len(self.object.barmen_list) < 1\n ): # Check that at least one barman is logged in\n return self.cancel(request)\n if self.object.type == \"BAR\" and not (\n \"counter_token\" in self.request.session\n and self.request.session[\"counter_token\"] == self.object.token\n ): # Also check the token to avoid the bar to be stolen\n return HttpResponseRedirect(\n reverse_lazy(\n \"counter:details\",\n args=self.args,\n kwargs={\"counter_id\": self.object.id},\n )\n + \"?bad_location\"\n )\n if \"basket\" not in request.session:\n request.session[\"basket\"] = {}\n request.session[\"basket_total\"] = 0\n request.session[\"not_enough\"] = False # Reset every variable\n request.session[\"too_young\"] = False\n request.session[\"not_allowed\"] = False\n request.session[\"no_age\"] = False\n request.session[\"not_valid_student_card_uid\"] = False\n if self.object.type != \"BAR\":\n self.operator = request.user\n elif self.customer_is_barman():\n self.operator = self.customer.user\n else:\n self.operator = self.object.get_random_barman()\n action = self.request.POST.get(\"action\", None)\n if action is None:\n action = parse_qs(request.body.decode()).get(\"action\", [\"\"])[0]\n if action == \"add_product\":\n self.add_product(request)\n elif action == \"add_student_card\":\n self.add_student_card(request)\n elif action == \"del_product\":\n self.del_product(request)\n elif action == \"refill\":\n self.refill(request)\n elif action == \"code\":\n return self.parse_code(request)\n elif action == \"cancel\":\n return self.cancel(request)\n elif action == \"finish\":\n return self.finish(request)\n context = self.get_context_data(object=self.object)\n return self.render_to_response(context)\n
"},{"location":"reference/counter/views/#counter.views.CounterClick.add_product","title":"add_product(request, q=1, p=None)
","text":"Add a product to the basket q is the quantity passed as integer p is the product id, passed as an integer.
Source code incounter/views.py
def add_product(self, request, q=1, p=None):\n \"\"\"Add a product to the basket\n q is the quantity passed as integer\n p is the product id, passed as an integer.\n \"\"\"\n pid = p or parse_qs(request.body.decode())[\"product_id\"][0]\n pid = str(pid)\n price = self.get_price(pid)\n total = self.sum_basket(request)\n product: Product = self.get_product(pid)\n user: User = self.customer.user\n buying_groups = list(product.buying_groups.values_list(\"pk\", flat=True))\n can_buy = len(buying_groups) == 0 or any(\n user.is_in_group(pk=group_id) for group_id in buying_groups\n )\n if not can_buy:\n request.session[\"not_allowed\"] = True\n return False\n bq = 0 # Bonus quantity, for trays\n if (\n product.tray\n ): # Handle the tray to adjust the quantity q to add and the bonus quantity bq\n total_qty_mod_6 = self.get_total_quantity_for_pid(request, pid) % 6\n bq = int((total_qty_mod_6 + q) / 6) # Integer division\n q -= bq\n if self.customer.amount < (\n total + round(q * float(price), 2)\n ): # Check for enough money\n request.session[\"not_enough\"] = True\n return False\n if product.is_unrecord_product and not self.is_record_product_ok(\n request, product\n ):\n request.session[\"not_allowed\"] = True\n return False\n if product.limit_age >= 18 and not user.date_of_birth:\n request.session[\"no_age\"] = True\n return False\n if product.limit_age >= 18 and user.is_banned_alcohol:\n request.session[\"not_allowed\"] = True\n return False\n if user.is_banned_counter:\n request.session[\"not_allowed\"] = True\n return False\n if (\n user.date_of_birth and self.customer.user.get_age() < product.limit_age\n ): # Check if affordable\n request.session[\"too_young\"] = True\n return False\n if pid in request.session[\"basket\"]: # Add if already in basket\n request.session[\"basket\"][pid][\"qty\"] += q\n request.session[\"basket\"][pid][\"bonus_qty\"] += bq\n else: # or create if not\n request.session[\"basket\"][pid] = {\n \"qty\": q,\n \"price\": int(price * 100),\n \"bonus_qty\": bq,\n }\n request.session.modified = True\n return True\n
"},{"location":"reference/counter/views/#counter.views.CounterClick.add_student_card","title":"add_student_card(request)
","text":"Add a new student card on the customer account.
Source code incounter/views.py
def add_student_card(self, request):\n \"\"\"Add a new student card on the customer account.\"\"\"\n uid = str(request.POST[\"student_card_uid\"])\n if not StudentCard.is_valid(uid):\n request.session[\"not_valid_student_card_uid\"] = True\n return False\n\n if not (\n self.object.type == \"BAR\"\n and \"counter_token\" in request.session\n and request.session[\"counter_token\"] == self.object.token\n and self.object.is_open\n ):\n raise PermissionDenied\n StudentCard(customer=self.customer, uid=uid).save()\n return True\n
"},{"location":"reference/counter/views/#counter.views.CounterClick.del_product","title":"del_product(request)
","text":"Delete a product from the basket.
Source code incounter/views.py
def del_product(self, request):\n \"\"\"Delete a product from the basket.\"\"\"\n pid = parse_qs(request.body.decode())[\"product_id\"][0]\n product = self.get_product(pid)\n if pid in request.session[\"basket\"]:\n if (\n product.tray\n and (self.get_total_quantity_for_pid(request, pid) % 6 == 0)\n and request.session[\"basket\"][pid][\"bonus_qty\"]\n ):\n request.session[\"basket\"][pid][\"bonus_qty\"] -= 1\n else:\n request.session[\"basket\"][pid][\"qty\"] -= 1\n if request.session[\"basket\"][pid][\"qty\"] <= 0:\n del request.session[\"basket\"][pid]\n request.session.modified = True\n
"},{"location":"reference/counter/views/#counter.views.CounterClick.parse_code","title":"parse_code(request)
","text":"Parse the string entered by the barman.
This can be of two forms<str>
, where the string is the code of the product<int>X<str>
, where the integer is the quantity and str the code.counter/views.py
def parse_code(self, request):\n \"\"\"Parse the string entered by the barman.\n\n This can be of two forms :\n - `<str>`, where the string is the code of the product\n - `<int>X<str>`, where the integer is the quantity and str the code.\n \"\"\"\n string = parse_qs(request.body.decode()).get(\"code\", [\"\"])[0].upper()\n if string == \"FIN\":\n return self.finish(request)\n elif string == \"ANN\":\n return self.cancel(request)\n regex = re.compile(r\"^((?P<nb>[0-9]+)X)?(?P<code>[A-Z0-9]+)$\")\n m = regex.match(string)\n if m is not None:\n nb = m.group(\"nb\")\n code = m.group(\"code\")\n nb = int(nb) if nb is not None else 1\n p = self.object.products.filter(code=code).first()\n if p is not None:\n self.add_product(request, nb, p.id)\n context = self.get_context_data(object=self.object)\n return self.render_to_response(context)\n
"},{"location":"reference/counter/views/#counter.views.CounterClick.finish","title":"finish(request)
","text":"Finish the click session, and validate the basket.
Source code incounter/views.py
def finish(self, request):\n \"\"\"Finish the click session, and validate the basket.\"\"\"\n with transaction.atomic():\n request.session[\"last_basket\"] = []\n if self.sum_basket(request) > self.customer.amount:\n raise DataError(_(\"You have not enough money to buy all the basket\"))\n\n for pid, infos in request.session[\"basket\"].items():\n # This duplicates code for DB optimization (prevent to load many times the same object)\n p = Product.objects.filter(pk=pid).first()\n if self.customer_is_barman():\n uprice = p.special_selling_price\n else:\n uprice = p.selling_price\n request.session[\"last_basket\"].append(\n \"%d x %s\" % (infos[\"qty\"] + infos[\"bonus_qty\"], p.name)\n )\n s = Selling(\n label=p.name,\n product=p,\n club=p.club,\n counter=self.object,\n unit_price=uprice,\n quantity=infos[\"qty\"],\n seller=self.operator,\n customer=self.customer,\n )\n s.save()\n if infos[\"bonus_qty\"]:\n s = Selling(\n label=p.name + \" (Plateau)\",\n product=p,\n club=p.club,\n counter=self.object,\n unit_price=0,\n quantity=infos[\"bonus_qty\"],\n seller=self.operator,\n customer=self.customer,\n )\n s.save()\n self.customer.recorded_products -= self.compute_record_product(request)\n self.customer.save()\n request.session[\"last_customer\"] = self.customer.user.get_display_name()\n request.session[\"last_total\"] = \"%0.2f\" % self.sum_basket(request)\n request.session[\"new_customer_amount\"] = str(self.customer.amount)\n del request.session[\"basket\"]\n request.session.modified = True\n kwargs = {\"counter_id\": self.object.id}\n return HttpResponseRedirect(\n reverse_lazy(\"counter:details\", args=self.args, kwargs=kwargs)\n )\n
"},{"location":"reference/counter/views/#counter.views.CounterClick.cancel","title":"cancel(request)
","text":"Cancel the click session.
Source code incounter/views.py
def cancel(self, request):\n \"\"\"Cancel the click session.\"\"\"\n kwargs = {\"counter_id\": self.object.id}\n request.session.pop(\"basket\", None)\n return HttpResponseRedirect(\n reverse_lazy(\"counter:details\", args=self.args, kwargs=kwargs)\n )\n
"},{"location":"reference/counter/views/#counter.views.CounterClick.refill","title":"refill(request)
","text":"Refill the customer's account.
Source code incounter/views.py
def refill(self, request):\n \"\"\"Refill the customer's account.\"\"\"\n if not self.object.can_refill():\n raise PermissionDenied\n form = RefillForm(request.POST)\n if form.is_valid():\n form.instance.counter = self.object\n form.instance.operator = self.operator\n form.instance.customer = self.customer\n form.instance.save()\n else:\n self.refill_form = form\n
"},{"location":"reference/counter/views/#counter.views.CounterClick.get_context_data","title":"get_context_data(**kwargs)
","text":"Add customer to the context.
Source code incounter/views.py
def get_context_data(self, **kwargs):\n \"\"\"Add customer to the context.\"\"\"\n kwargs = super().get_context_data(**kwargs)\n products = self.object.products.select_related(\"product_type\")\n if self.customer_is_barman():\n products = products.annotate(price=F(\"special_selling_price\"))\n else:\n products = products.annotate(price=F(\"selling_price\"))\n kwargs[\"products\"] = products\n kwargs[\"categories\"] = {}\n for product in kwargs[\"products\"]:\n if product.product_type:\n kwargs[\"categories\"].setdefault(product.product_type, []).append(\n product\n )\n kwargs[\"customer\"] = self.customer\n kwargs[\"student_cards\"] = self.customer.student_cards.all()\n kwargs[\"student_card_input\"] = NFCCardForm()\n kwargs[\"basket_total\"] = self.sum_basket(self.request)\n kwargs[\"refill_form\"] = self.refill_form or RefillForm()\n kwargs[\"student_card_max_uid_size\"] = StudentCard.UID_SIZE\n kwargs[\"barmens_can_refill\"] = self.object.can_refill()\n return kwargs\n
"},{"location":"reference/counter/views/#counter.views.CounterAdminTabsMixin","title":"CounterAdminTabsMixin
","text":" Bases: TabedViewMixin
CounterListView
","text":" Bases: CounterAdminTabsMixin
, CanViewMixin
, ListView
A list view for the admins.
"},{"location":"reference/counter/views/#counter.views.CounterEditView","title":"CounterEditView
","text":" Bases: CounterAdminTabsMixin
, CounterAdminMixin
, UpdateView
Edit a counter's main informations (for the counter's manager).
"},{"location":"reference/counter/views/#counter.views.CounterEditPropView","title":"CounterEditPropView
","text":" Bases: CounterAdminTabsMixin
, CounterAdminMixin
, UpdateView
Edit a counter's main informations (for the counter's admin).
"},{"location":"reference/counter/views/#counter.views.CounterCreateView","title":"CounterCreateView
","text":" Bases: CounterAdminTabsMixin
, CounterAdminMixin
, CreateView
Create a counter (for the admins).
"},{"location":"reference/counter/views/#counter.views.CounterDeleteView","title":"CounterDeleteView
","text":" Bases: CounterAdminTabsMixin
, CounterAdminMixin
, DeleteView
Delete a counter (for the admins).
"},{"location":"reference/counter/views/#counter.views.ProductTypeListView","title":"ProductTypeListView
","text":" Bases: CounterAdminTabsMixin
, CounterAdminMixin
, ListView
A list view for the admins.
"},{"location":"reference/counter/views/#counter.views.ProductTypeCreateView","title":"ProductTypeCreateView
","text":" Bases: CounterAdminTabsMixin
, CounterAdminMixin
, CreateView
A create view for the admins.
"},{"location":"reference/counter/views/#counter.views.ProductTypeEditView","title":"ProductTypeEditView
","text":" Bases: CounterAdminTabsMixin
, CounterAdminMixin
, UpdateView
An edit view for the admins.
"},{"location":"reference/counter/views/#counter.views.ProductListView","title":"ProductListView
","text":" Bases: CounterAdminTabsMixin
, CounterAdminMixin
, ListView
ArchivedProductListView
","text":" Bases: ProductListView
A list view for the admins.
"},{"location":"reference/counter/views/#counter.views.ActiveProductListView","title":"ActiveProductListView
","text":" Bases: ProductListView
A list view for the admins.
"},{"location":"reference/counter/views/#counter.views.ProductCreateView","title":"ProductCreateView
","text":" Bases: CounterAdminTabsMixin
, CounterAdminMixin
, CreateView
A create view for the admins.
"},{"location":"reference/counter/views/#counter.views.ProductEditView","title":"ProductEditView
","text":" Bases: CounterAdminTabsMixin
, CounterAdminMixin
, UpdateView
An edit view for the admins.
"},{"location":"reference/counter/views/#counter.views.RefillingDeleteView","title":"RefillingDeleteView
","text":" Bases: DeleteView
Delete a refilling (for the admins).
"},{"location":"reference/counter/views/#counter.views.RefillingDeleteView.dispatch","title":"dispatch(request, *args, **kwargs)
","text":"We have here a very particular right handling, we can't inherit from CanEditPropMixin.
Source code incounter/views.py
def dispatch(self, request, *args, **kwargs):\n \"\"\"We have here a very particular right handling, we can't inherit from CanEditPropMixin.\"\"\"\n self.object = self.get_object()\n if timezone.now() - self.object.date <= timedelta(\n minutes=settings.SITH_LAST_OPERATIONS_LIMIT\n ) and is_logged_in_counter(request):\n self.success_url = reverse(\n \"counter:details\", kwargs={\"counter_id\": self.object.counter.id}\n )\n return super().dispatch(request, *args, **kwargs)\n elif self.object.is_owned_by(request.user):\n self.success_url = reverse(\n \"core:user_account\", kwargs={\"user_id\": self.object.customer.user.id}\n )\n return super().dispatch(request, *args, **kwargs)\n raise PermissionDenied\n
"},{"location":"reference/counter/views/#counter.views.SellingDeleteView","title":"SellingDeleteView
","text":" Bases: DeleteView
Delete a selling (for the admins).
"},{"location":"reference/counter/views/#counter.views.SellingDeleteView.dispatch","title":"dispatch(request, *args, **kwargs)
","text":"We have here a very particular right handling, we can't inherit from CanEditPropMixin.
Source code incounter/views.py
def dispatch(self, request, *args, **kwargs):\n \"\"\"We have here a very particular right handling, we can't inherit from CanEditPropMixin.\"\"\"\n self.object = self.get_object()\n if timezone.now() - self.object.date <= timedelta(\n minutes=settings.SITH_LAST_OPERATIONS_LIMIT\n ) and is_logged_in_counter(request):\n self.success_url = reverse(\n \"counter:details\", kwargs={\"counter_id\": self.object.counter.id}\n )\n return super().dispatch(request, *args, **kwargs)\n elif self.object.is_owned_by(request.user):\n self.success_url = reverse(\n \"core:user_account\", kwargs={\"user_id\": self.object.customer.user.id}\n )\n return super().dispatch(request, *args, **kwargs)\n raise PermissionDenied\n
"},{"location":"reference/counter/views/#counter.views.CashRegisterSummaryForm","title":"CashRegisterSummaryForm(*args, **kwargs)
","text":" Bases: Form
Provide the cash summary form.
Source code incounter/views.py
def __init__(self, *args, **kwargs):\n instance = kwargs.pop(\"instance\", None)\n super().__init__(*args, **kwargs)\n if instance:\n self.fields[\"ten_cents\"].initial = (\n instance.ten_cents.quantity if instance.ten_cents else 0\n )\n self.fields[\"twenty_cents\"].initial = (\n instance.twenty_cents.quantity if instance.twenty_cents else 0\n )\n self.fields[\"fifty_cents\"].initial = (\n instance.fifty_cents.quantity if instance.fifty_cents else 0\n )\n self.fields[\"one_euro\"].initial = (\n instance.one_euro.quantity if instance.one_euro else 0\n )\n self.fields[\"two_euros\"].initial = (\n instance.two_euros.quantity if instance.two_euros else 0\n )\n self.fields[\"five_euros\"].initial = (\n instance.five_euros.quantity if instance.five_euros else 0\n )\n self.fields[\"ten_euros\"].initial = (\n instance.ten_euros.quantity if instance.ten_euros else 0\n )\n self.fields[\"twenty_euros\"].initial = (\n instance.twenty_euros.quantity if instance.twenty_euros else 0\n )\n self.fields[\"fifty_euros\"].initial = (\n instance.fifty_euros.quantity if instance.fifty_euros else 0\n )\n self.fields[\"hundred_euros\"].initial = (\n instance.hundred_euros.quantity if instance.hundred_euros else 0\n )\n self.fields[\"check_1_quantity\"].initial = (\n instance.check_1.quantity if instance.check_1 else 0\n )\n self.fields[\"check_2_quantity\"].initial = (\n instance.check_2.quantity if instance.check_2 else 0\n )\n self.fields[\"check_3_quantity\"].initial = (\n instance.check_3.quantity if instance.check_3 else 0\n )\n self.fields[\"check_4_quantity\"].initial = (\n instance.check_4.quantity if instance.check_4 else 0\n )\n self.fields[\"check_5_quantity\"].initial = (\n instance.check_5.quantity if instance.check_5 else 0\n )\n self.fields[\"check_1_value\"].initial = (\n instance.check_1.value if instance.check_1 else 0\n )\n self.fields[\"check_2_value\"].initial = (\n instance.check_2.value if instance.check_2 else 0\n )\n self.fields[\"check_3_value\"].initial = (\n instance.check_3.value if instance.check_3 else 0\n )\n self.fields[\"check_4_value\"].initial = (\n instance.check_4.value if instance.check_4 else 0\n )\n self.fields[\"check_5_value\"].initial = (\n instance.check_5.value if instance.check_5 else 0\n )\n self.fields[\"comment\"].initial = instance.comment\n self.fields[\"emptied\"].initial = instance.emptied\n self.instance = instance\n else:\n self.instance = None\n
"},{"location":"reference/counter/views/#counter.views.CounterLastOperationsView","title":"CounterLastOperationsView
","text":" Bases: CounterTabsMixin
, CanViewMixin
, DetailView
Provide the last operations to allow barmen to delete them.
"},{"location":"reference/counter/views/#counter.views.CounterLastOperationsView.dispatch","title":"dispatch(request, *args, **kwargs)
","text":"We have here again a very particular right handling.
Source code incounter/views.py
def dispatch(self, request, *args, **kwargs):\n \"\"\"We have here again a very particular right handling.\"\"\"\n self.object = self.get_object()\n if is_logged_in_counter(request) and self.object.barmen_list:\n return super().dispatch(request, *args, **kwargs)\n return HttpResponseRedirect(\n reverse(\"counter:details\", kwargs={\"counter_id\": self.object.id})\n + \"?bad_location\"\n )\n
"},{"location":"reference/counter/views/#counter.views.CounterLastOperationsView.get_context_data","title":"get_context_data(**kwargs)
","text":"Add form to the context.
Source code incounter/views.py
def get_context_data(self, **kwargs):\n \"\"\"Add form to the context.\"\"\"\n kwargs = super().get_context_data(**kwargs)\n threshold = timezone.now() - timedelta(\n minutes=settings.SITH_LAST_OPERATIONS_LIMIT\n )\n kwargs[\"last_refillings\"] = (\n self.object.refillings.filter(date__gte=threshold)\n .select_related(\"operator\", \"customer__user\")\n .order_by(\"-id\")[:20]\n )\n kwargs[\"last_sellings\"] = (\n self.object.sellings.filter(date__gte=threshold)\n .select_related(\"seller\", \"customer__user\")\n .order_by(\"-id\")[:20]\n )\n return kwargs\n
"},{"location":"reference/counter/views/#counter.views.CounterCashSummaryView","title":"CounterCashSummaryView
","text":" Bases: CounterTabsMixin
, CanViewMixin
, DetailView
Provide the cash summary form.
"},{"location":"reference/counter/views/#counter.views.CounterCashSummaryView.dispatch","title":"dispatch(request, *args, **kwargs)
","text":"We have here again a very particular right handling.
Source code incounter/views.py
def dispatch(self, request, *args, **kwargs):\n \"\"\"We have here again a very particular right handling.\"\"\"\n self.object = self.get_object()\n if is_logged_in_counter(request) and self.object.barmen_list:\n return super().dispatch(request, *args, **kwargs)\n return HttpResponseRedirect(\n reverse(\"counter:details\", kwargs={\"counter_id\": self.object.id})\n + \"?bad_location\"\n )\n
"},{"location":"reference/counter/views/#counter.views.CounterCashSummaryView.get_context_data","title":"get_context_data(**kwargs)
","text":"Add form to the context.
Source code incounter/views.py
def get_context_data(self, **kwargs):\n \"\"\"Add form to the context.\"\"\"\n kwargs = super().get_context_data(**kwargs)\n kwargs[\"form\"] = self.form\n return kwargs\n
"},{"location":"reference/counter/views/#counter.views.CounterActivityView","title":"CounterActivityView
","text":" Bases: DetailView
Show the bar activity.
"},{"location":"reference/counter/views/#counter.views.CounterStatView","title":"CounterStatView
","text":" Bases: DetailView
, CounterAdminMixin
Show the bar stats.
"},{"location":"reference/counter/views/#counter.views.CounterStatView.get_context_data","title":"get_context_data(**kwargs)
","text":"Add stats to the context.
Source code incounter/views.py
def get_context_data(self, **kwargs):\n \"\"\"Add stats to the context.\"\"\"\n counter: Counter = self.object\n semester_start = get_start_of_semester()\n office_hours = counter.get_top_barmen()\n kwargs = super().get_context_data(**kwargs)\n kwargs.update(\n {\n \"counter\": counter,\n \"current_semester\": get_semester_code(),\n \"total_sellings\": counter.get_total_sales(since=semester_start),\n \"top_customers\": counter.get_top_customers(since=semester_start)[:100],\n \"top_barman\": office_hours[:100],\n \"top_barman_semester\": (\n office_hours.filter(start__gt=semester_start)[:100]\n ),\n }\n )\n return kwargs\n
"},{"location":"reference/counter/views/#counter.views.CashSummaryEditView","title":"CashSummaryEditView
","text":" Bases: CounterAdminTabsMixin
, CounterAdminMixin
, UpdateView
Edit cash summaries.
"},{"location":"reference/counter/views/#counter.views.CashSummaryListView","title":"CashSummaryListView
","text":" Bases: CounterAdminTabsMixin
, CounterAdminMixin
, ListView
Display a list of cash summaries.
"},{"location":"reference/counter/views/#counter.views.CashSummaryListView.get_context_data","title":"get_context_data(**kwargs)
","text":"Add sums to the context.
Source code incounter/views.py
def get_context_data(self, **kwargs):\n \"\"\"Add sums to the context.\"\"\"\n kwargs = super().get_context_data(**kwargs)\n form = CashSummaryFormBase(self.request.GET)\n kwargs[\"form\"] = form\n kwargs[\"summaries_sums\"] = {}\n kwargs[\"refilling_sums\"] = {}\n for c in Counter.objects.filter(type=\"BAR\").all():\n refillings = Refilling.objects.filter(counter=c)\n cashredistersummaries = CashRegisterSummary.objects.filter(counter=c)\n if form.is_valid() and form.cleaned_data[\"begin_date\"]:\n refillings = refillings.filter(\n date__gte=form.cleaned_data[\"begin_date\"]\n )\n cashredistersummaries = cashredistersummaries.filter(\n date__gte=form.cleaned_data[\"begin_date\"]\n )\n else:\n last_summary = (\n CashRegisterSummary.objects.filter(counter=c, emptied=True)\n .order_by(\"-date\")\n .first()\n )\n if last_summary:\n refillings = refillings.filter(date__gt=last_summary.date)\n cashredistersummaries = cashredistersummaries.filter(\n date__gt=last_summary.date\n )\n else:\n refillings = refillings.filter(\n date__gte=datetime(year=1994, month=5, day=17, tzinfo=tz.utc)\n ) # My birth date should be old enough\n cashredistersummaries = cashredistersummaries.filter(\n date__gte=datetime(year=1994, month=5, day=17, tzinfo=tz.utc)\n )\n if form.is_valid() and form.cleaned_data[\"end_date\"]:\n refillings = refillings.filter(date__lte=form.cleaned_data[\"end_date\"])\n cashredistersummaries = cashredistersummaries.filter(\n date__lte=form.cleaned_data[\"end_date\"]\n )\n kwargs[\"summaries_sums\"][c.name] = sum(\n [s.get_total() for s in cashredistersummaries.all()]\n )\n kwargs[\"refilling_sums\"][c.name] = sum([s.amount for s in refillings.all()])\n return kwargs\n
"},{"location":"reference/counter/views/#counter.views.InvoiceCallView","title":"InvoiceCallView
","text":" Bases: CounterAdminTabsMixin
, CounterAdminMixin
, TemplateView
get_context_data(**kwargs)
","text":"Add sums to the context.
Source code incounter/views.py
def get_context_data(self, **kwargs):\n \"\"\"Add sums to the context.\"\"\"\n kwargs = super().get_context_data(**kwargs)\n kwargs[\"months\"] = Selling.objects.datetimes(\"date\", \"month\", order=\"DESC\")\n if \"month\" in self.request.GET:\n start_date = datetime.strptime(self.request.GET[\"month\"], \"%Y-%m\")\n else:\n start_date = datetime(\n year=timezone.now().year,\n month=(timezone.now().month + 10) % 12 + 1,\n day=1,\n )\n start_date = start_date.replace(tzinfo=tz.utc)\n end_date = (start_date + timedelta(days=32)).replace(\n day=1, hour=0, minute=0, microsecond=0\n )\n from django.db.models import Case, F, Sum, When\n\n kwargs[\"sum_cb\"] = sum(\n [\n r.amount\n for r in Refilling.objects.filter(\n payment_method=\"CARD\",\n is_validated=True,\n date__gte=start_date,\n date__lte=end_date,\n )\n ]\n )\n kwargs[\"sum_cb\"] += sum(\n [\n s.quantity * s.unit_price\n for s in Selling.objects.filter(\n payment_method=\"CARD\",\n is_validated=True,\n date__gte=start_date,\n date__lte=end_date,\n )\n ]\n )\n kwargs[\"start_date\"] = start_date\n kwargs[\"sums\"] = (\n Selling.objects.values(\"club__name\")\n .annotate(\n selling_sum=Sum(\n Case(\n When(\n date__gte=start_date,\n date__lt=end_date,\n then=F(\"unit_price\") * F(\"quantity\"),\n ),\n output_field=CurrencyField(),\n )\n )\n )\n .exclude(selling_sum=None)\n .order_by(\"-selling_sum\")\n )\n return kwargs\n
"},{"location":"reference/counter/views/#counter.views.EticketListView","title":"EticketListView
","text":" Bases: CounterAdminTabsMixin
, CounterAdminMixin
, ListView
A list view for the admins.
"},{"location":"reference/counter/views/#counter.views.EticketCreateView","title":"EticketCreateView
","text":" Bases: CounterAdminTabsMixin
, CounterAdminMixin
, CreateView
Create an eticket.
"},{"location":"reference/counter/views/#counter.views.EticketEditView","title":"EticketEditView
","text":" Bases: CounterAdminTabsMixin
, CounterAdminMixin
, UpdateView
Edit an eticket.
"},{"location":"reference/counter/views/#counter.views.EticketPDFView","title":"EticketPDFView
","text":" Bases: CanViewMixin
, DetailView
Display the PDF of an eticket.
"},{"location":"reference/counter/views/#counter.views.CounterRefillingListView","title":"CounterRefillingListView
","text":" Bases: CounterAdminTabsMixin
, CounterAdminMixin
, ListView
List of refillings on a counter.
"},{"location":"reference/counter/views/#counter.views.StudentCardFormView","title":"StudentCardFormView
","text":" Bases: FormView
Add a new student card.
"},{"location":"reference/counter/views/#counter.views.get_semester_code","title":"get_semester_code(d=None)
","text":"Return the semester code of the given date. If no date is given, return the semester code of the current semester.
The semester code is an upper letter (A for autumn, P for spring), followed by the last two digits of the year. For example, the autumn semester of 2018 is \"A18\".
Parameters:
Name Type Description Defaultd
Optional[date]
the date to use to compute the semester. If None, use today's date.
None
Returns:
Type Descriptionstr
the semester code corresponding to the given date
Source code incore/utils.py
def get_semester_code(d: Optional[date] = None) -> str:\n \"\"\"Return the semester code of the given date.\n If no date is given, return the semester code of the current semester.\n\n The semester code is an upper letter (A for autumn, P for spring),\n followed by the last two digits of the year.\n For example, the autumn semester of 2018 is \"A18\".\n\n Args:\n d: the date to use to compute the semester. If None, use today's date.\n\n Returns:\n the semester code corresponding to the given date\n \"\"\"\n if d is None:\n d = localdate()\n\n start = get_start_of_semester(d)\n\n if (start.month, start.day) == settings.SITH_SEMESTER_START_AUTUMN:\n return \"A\" + str(start.year)[-2:]\n return \"P\" + str(start.year)[-2:]\n
"},{"location":"reference/counter/views/#counter.views.get_start_of_semester","title":"get_start_of_semester(today=None)
","text":"Return the date of the start of the semester of the given date. If no date is given, return the start date of the current semester.
The current semester is computed as follows:
Parameters:
Name Type Description Defaulttoday
Optional[date]
the date to use to compute the semester. If None, use today's date.
None
Returns:
Type Descriptiondate
the date of the start of the semester
Source code incore/utils.py
def get_start_of_semester(today: Optional[date] = None) -> date:\n \"\"\"Return the date of the start of the semester of the given date.\n If no date is given, return the start date of the current semester.\n\n The current semester is computed as follows:\n\n - If the date is between 15/08 and 31/12 => Autumn semester.\n - If the date is between 01/01 and 15/02 => Autumn semester of the previous year.\n - If the date is between 15/02 and 15/08 => Spring semester\n\n Args:\n today: the date to use to compute the semester. If None, use today's date.\n\n Returns:\n the date of the start of the semester\n \"\"\"\n if today is None:\n today = localdate()\n\n autumn = date(today.year, *settings.SITH_SEMESTER_START_AUTUMN)\n spring = date(today.year, *settings.SITH_SEMESTER_START_SPRING)\n\n if today >= autumn: # between 15/08 (included) and 31/12 -> autumn semester\n return autumn\n if today >= spring: # between 15/02 (included) and 15/08 -> spring semester\n return spring\n # between 01/01 and 15/02 -> autumn semester of the previous year\n return autumn.replace(year=autumn.year - 1)\n
"},{"location":"reference/counter/views/#counter.views.is_logged_in_counter","title":"is_logged_in_counter(request)
","text":"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 :
counter/utils.py
def is_logged_in_counter(request: HttpRequest) -> bool:\n \"\"\"Check if the request is sent from a device logged to a counter.\n\n The request must also be sent within the frame of a counter's activity.\n Trying to use this function to manage access to non-sas\n related resources probably won't work.\n\n A request is considered as coming from a logged counter if :\n\n - Its referer comes from the counter app\n (eg. fetching user pictures from the click UI)\n or the request path belongs to the counter app\n (eg. the barman went back to the main by missclick and go back\n to the counter)\n - The current session has a counter token associated with it.\n - A counter with this token exists.\n \"\"\"\n referer_ok = (\n \"HTTP_REFERER\" in request.META\n and resolve(urlparse(request.META[\"HTTP_REFERER\"]).path).app_name == \"counter\"\n )\n return (\n (referer_ok or request.resolver_match.app_name == \"counter\")\n and \"counter_token\" in request.session\n and request.session[\"counter_token\"]\n and Counter.objects.filter(token=request.session[\"counter_token\"]).exists()\n )\n
"},{"location":"reference/counter/views/#counter.views.counter_login","title":"counter_login(request, counter_id)
","text":"Log a user in a counter.
A successful login will result in the beginning of a counter duty for the user.
Source code incounter/views.py
@require_POST\ndef counter_login(request: HttpRequest, counter_id: int) -> HttpResponseRedirect:\n \"\"\"Log a user in a counter.\n\n A successful login will result in the beginning of a counter duty\n for the user.\n \"\"\"\n counter = get_object_or_404(Counter, pk=counter_id)\n form = LoginForm(request, data=request.POST)\n if not form.is_valid():\n return redirect(counter.get_absolute_url() + \"?credentials\")\n user = form.get_user()\n if not counter.sellers.contains(user) or user in counter.barmen_list:\n return redirect(counter.get_absolute_url() + \"?sellers\")\n if len(counter.barmen_list) == 0:\n counter.gen_token()\n request.session[\"counter_token\"] = counter.token\n counter.permanencies.create(user=user, start=timezone.now())\n return redirect(counter)\n
"},{"location":"reference/counter/views/#counter.views.counter_logout","title":"counter_logout(request, counter_id)
","text":"End the permanency of a user in this counter.
Source code incounter/views.py
@require_POST\ndef counter_logout(request: HttpRequest, counter_id: int) -> HttpResponseRedirect:\n \"\"\"End the permanency of a user in this counter.\"\"\"\n Permanency.objects.filter(counter=counter_id, user=request.POST[\"user_id\"]).update(\n end=F(\"activity\")\n )\n return redirect(\"counter:details\", counter_id=counter_id)\n
"},{"location":"reference/eboutic/models/","title":"Models","text":""},{"location":"reference/eboutic/models/#eboutic.models.CurrencyField","title":"CurrencyField(*args, **kwargs)
","text":" Bases: DecimalField
Custom database field used for currency.
Source code inaccounting/models.py
def __init__(self, *args, **kwargs):\n kwargs[\"max_digits\"] = 12\n kwargs[\"decimal_places\"] = 2\n super().__init__(*args, **kwargs)\n
"},{"location":"reference/eboutic/models/#eboutic.models.User","title":"User
","text":" Bases: AbstractBaseUser
Defines the base user class, useable in every app.
This is almost the same as the auth module AbstractUser since it inherits from it, but some fields are required, and the username is generated automatically with the name of the user (see generate_username()).
Added field: nick_name, date_of_birth Required fields: email, first_name, last_name, date_of_birth
"},{"location":"reference/eboutic/models/#eboutic.models.User.cached_groups","title":"cached_groups: list[Group]
property
","text":"Get the list of groups this user is in.
The result is cached for the default duration (should be 5 minutes)
Returns: A list of all the groups this user is in.
"},{"location":"reference/eboutic/models/#eboutic.models.User.is_in_group","title":"is_in_group(*, pk=None, name=None)
","text":"Check if this user is in the given group. Either a group id or a group name must be provided. If both are passed, only the id will be considered.
The group will be fetched using the given parameter. If no group is found, return False. If a group is found, check if this user is in the latter.
Returns:
Type Descriptionbool
True if the user is the group, else False
Source code incore/models.py
def is_in_group(self, *, pk: int | None = None, name: str | None = None) -> bool:\n \"\"\"Check if this user is in the given group.\n Either a group id or a group name must be provided.\n If both are passed, only the id will be considered.\n\n The group will be fetched using the given parameter.\n If no group is found, return False.\n If a group is found, check if this user is in the latter.\n\n Returns:\n True if the user is the group, else False\n \"\"\"\n if pk is not None:\n group: Optional[Group] = get_group(pk=pk)\n elif name is not None:\n group: Optional[Group] = get_group(name=name)\n else:\n raise ValueError(\"You must either provide the id or the name of the group\")\n if group is None:\n return False\n if group.id == settings.SITH_GROUP_PUBLIC_ID:\n return True\n if group.id == settings.SITH_GROUP_SUBSCRIBERS_ID:\n return self.is_subscribed\n if group.id == settings.SITH_GROUP_OLD_SUBSCRIBERS_ID:\n return self.was_subscribed\n if group.id == settings.SITH_GROUP_ROOT_ID:\n return self.is_root\n if group.is_meta:\n # check if this group is associated with a club\n group.__class__ = MetaGroup\n club = group.associated_club\n if club is None:\n return False\n membership = club.get_membership_for(self)\n if membership is None:\n return False\n if group.name.endswith(settings.SITH_MEMBER_SUFFIX):\n return True\n return membership.role > settings.SITH_MAXIMUM_FREE_ROLE\n return group in self.cached_groups\n
"},{"location":"reference/eboutic/models/#eboutic.models.User.age","title":"age()
","text":"Return the age this user has the day the method is called. If the user has not filled his age, return 0.
Source code incore/models.py
@cached_property\ndef age(self) -> int:\n \"\"\"Return the age this user has the day the method is called.\n If the user has not filled his age, return 0.\n \"\"\"\n if self.date_of_birth is None:\n return 0\n today = timezone.now()\n age = today.year - self.date_of_birth.year\n # remove a year if this year's birthday is yet to come\n age -= (today.month, today.day) < (\n self.date_of_birth.month,\n self.date_of_birth.day,\n )\n return age\n
"},{"location":"reference/eboutic/models/#eboutic.models.User.get_full_name","title":"get_full_name()
","text":"Returns the first_name plus the last_name, with a space in between.
Source code incore/models.py
def get_full_name(self):\n \"\"\"Returns the first_name plus the last_name, with a space in between.\"\"\"\n full_name = \"%s %s\" % (self.first_name, self.last_name)\n return full_name.strip()\n
"},{"location":"reference/eboutic/models/#eboutic.models.User.get_short_name","title":"get_short_name()
","text":"Returns the short name for the user.
Source code incore/models.py
def get_short_name(self):\n \"\"\"Returns the short name for the user.\"\"\"\n if self.nick_name:\n return self.nick_name\n return self.first_name + \" \" + self.last_name\n
"},{"location":"reference/eboutic/models/#eboutic.models.User.get_display_name","title":"get_display_name()
","text":"Returns the display name of the user.
A nickname if possible, otherwise, the full name.
Source code incore/models.py
def get_display_name(self) -> str:\n \"\"\"Returns the display name of the user.\n\n A nickname if possible, otherwise, the full name.\n \"\"\"\n if self.nick_name:\n return \"%s (%s)\" % (self.get_full_name(), self.nick_name)\n return self.get_full_name()\n
"},{"location":"reference/eboutic/models/#eboutic.models.User.get_age","title":"get_age()
","text":"Returns the age.
Source code incore/models.py
def get_age(self):\n \"\"\"Returns the age.\"\"\"\n today = timezone.now()\n born = self.date_of_birth\n return (\n today.year - born.year - ((today.month, today.day) < (born.month, born.day))\n )\n
"},{"location":"reference/eboutic/models/#eboutic.models.User.get_family","title":"get_family(godfathers_depth=4, godchildren_depth=4)
","text":"Get the family of the user, with the given depth.
Parameters:
Name Type Description Defaultgodfathers_depth
NonNegativeInt
The number of generations of godfathers to fetch
4
godchildren_depth
NonNegativeInt
The number of generations of godchildren to fetch
4
Returns:
Type Descriptionset[through]
A list of family relationships in this user's family
Source code incore/models.py
def get_family(\n self,\n godfathers_depth: NonNegativeInt = 4,\n godchildren_depth: NonNegativeInt = 4,\n) -> set[User.godfathers.through]:\n \"\"\"Get the family of the user, with the given depth.\n\n Args:\n godfathers_depth: The number of generations of godfathers to fetch\n godchildren_depth: The number of generations of godchildren to fetch\n\n Returns:\n A list of family relationships in this user's family\n \"\"\"\n res = []\n for depth, key, reverse_key in [\n (godfathers_depth, \"from_user_id\", \"to_user_id\"),\n (godchildren_depth, \"to_user_id\", \"from_user_id\"),\n ]:\n if depth == 0:\n continue\n links = list(User.godfathers.through.objects.filter(**{key: self.id}))\n res.extend(links)\n for _ in range(1, depth): # noqa: F402 we don't care about gettext here\n ids = [getattr(c, reverse_key) for c in links]\n links = list(\n User.godfathers.through.objects.filter(\n **{f\"{key}__in\": ids}\n ).exclude(id__in=[r.id for r in res])\n )\n if not links:\n break\n res.extend(links)\n return set(res)\n
"},{"location":"reference/eboutic/models/#eboutic.models.User.email_user","title":"email_user(subject, message, from_email=None, **kwargs)
","text":"Sends an email to this User.
Source code incore/models.py
def email_user(self, subject, message, from_email=None, **kwargs):\n \"\"\"Sends an email to this User.\"\"\"\n if from_email is None:\n from_email = settings.DEFAULT_FROM_EMAIL\n send_mail(subject, message, from_email, [self.email], **kwargs)\n
"},{"location":"reference/eboutic/models/#eboutic.models.User.generate_username","title":"generate_username()
","text":"Generates a unique username based on the first and last names.
For example: Guy Carlier gives gcarlier, and gcarlier1 if the first one exists.
Returns:
Type Descriptionstr
The generated username.
Source code incore/models.py
def generate_username(self) -> str:\n \"\"\"Generates a unique username based on the first and last names.\n\n For example: Guy Carlier gives gcarlier, and gcarlier1 if the first one exists.\n\n Returns:\n The generated username.\n \"\"\"\n\n def remove_accents(data):\n return \"\".join(\n x\n for x in unicodedata.normalize(\"NFKD\", data)\n if unicodedata.category(x)[0] == \"L\"\n ).lower()\n\n user_name = (\n remove_accents(self.first_name[0] + self.last_name)\n .encode(\"ascii\", \"ignore\")\n .decode(\"utf-8\")\n )\n un_set = [u.username for u in User.objects.all()]\n if user_name in un_set:\n i = 1\n while user_name + str(i) in un_set:\n i += 1\n user_name += str(i)\n self.username = user_name\n return user_name\n
"},{"location":"reference/eboutic/models/#eboutic.models.User.is_owner","title":"is_owner(obj)
","text":"Determine if the object is owned by the user.
Source code incore/models.py
def is_owner(self, obj):\n \"\"\"Determine if the object is owned by the user.\"\"\"\n if hasattr(obj, \"is_owned_by\") and obj.is_owned_by(self):\n return True\n if hasattr(obj, \"owner_group\") and self.is_in_group(pk=obj.owner_group.id):\n return True\n return self.is_root\n
"},{"location":"reference/eboutic/models/#eboutic.models.User.can_edit","title":"can_edit(obj)
","text":"Determine if the object can be edited by the user.
Source code incore/models.py
def can_edit(self, obj):\n \"\"\"Determine if the object can be edited by the user.\"\"\"\n if hasattr(obj, \"can_be_edited_by\") and obj.can_be_edited_by(self):\n return True\n if hasattr(obj, \"edit_groups\"):\n for pk in obj.edit_groups.values_list(\"pk\", flat=True):\n if self.is_in_group(pk=pk):\n return True\n if isinstance(obj, User) and obj == self:\n return True\n return self.is_owner(obj)\n
"},{"location":"reference/eboutic/models/#eboutic.models.User.can_view","title":"can_view(obj)
","text":"Determine if the object can be viewed by the user.
Source code incore/models.py
def can_view(self, obj):\n \"\"\"Determine if the object can be viewed by the user.\"\"\"\n if hasattr(obj, \"can_be_viewed_by\") and obj.can_be_viewed_by(self):\n return True\n if hasattr(obj, \"view_groups\"):\n for pk in obj.view_groups.values_list(\"pk\", flat=True):\n if self.is_in_group(pk=pk):\n return True\n return self.can_edit(obj)\n
"},{"location":"reference/eboutic/models/#eboutic.models.User.clubs_with_rights","title":"clubs_with_rights()
","text":"The list of clubs where the user has rights
Source code incore/models.py
@cached_property\ndef clubs_with_rights(self) -> list[Club]:\n \"\"\"The list of clubs where the user has rights\"\"\"\n memberships = self.memberships.ongoing().board().select_related(\"club\")\n return [m.club for m in memberships]\n
"},{"location":"reference/eboutic/models/#eboutic.models.BillingInfo","title":"BillingInfo
","text":" Bases: Model
Represent the billing information of a user, which are required by the 3D-Secure v2 system used by the etransaction module.
"},{"location":"reference/eboutic/models/#eboutic.models.BillingInfo.to_3dsv2_xml","title":"to_3dsv2_xml()
","text":"Convert the data from this model into a xml usable by the online paying service of the Cr\u00e9dit Agricole bank. see : https://www.ca-moncommerce.com/espace-client-mon-commerce/up2pay-e-transactions/ma-documentation/manuel-dintegration-focus-3ds-v2/principes-generaux/#integration-3dsv2-developpeur-webmaster
.
counter/models.py
def to_3dsv2_xml(self) -> str:\n \"\"\"Convert the data from this model into a xml usable\n by the online paying service of the Cr\u00e9dit Agricole bank.\n see : `https://www.ca-moncommerce.com/espace-client-mon-commerce/up2pay-e-transactions/ma-documentation/manuel-dintegration-focus-3ds-v2/principes-generaux/#integration-3dsv2-developpeur-webmaster`.\n \"\"\"\n data = {\n \"Address\": {\n \"FirstName\": self.first_name,\n \"LastName\": self.last_name,\n \"Address1\": self.address_1,\n \"ZipCode\": self.zip_code,\n \"City\": self.city,\n \"CountryCode\": self.country.numeric, # ISO-3166-1 numeric code\n \"MobilePhone\": self.phone_number.as_national.replace(\" \", \"\"),\n \"CountryCodeMobilePhone\": f\"+{self.phone_number.country_code}\",\n }\n }\n if self.address_2:\n data[\"Address\"][\"Address2\"] = self.address_2\n xml = dict2xml(data, wrap=\"Billing\", newlines=False)\n return '<?xml version=\"1.0\" encoding=\"UTF-8\" ?>' + xml\n
"},{"location":"reference/eboutic/models/#eboutic.models.Counter","title":"Counter
","text":" Bases: Model
gen_token()
","text":"Generate a new token for this counter.
Source code incounter/models.py
def gen_token(self) -> None:\n \"\"\"Generate a new token for this counter.\"\"\"\n self.token = \"\".join(\n random.choice(string.ascii_letters + string.digits) for _ in range(30)\n )\n self.save()\n
"},{"location":"reference/eboutic/models/#eboutic.models.Counter.barmen_list","title":"barmen_list()
","text":"Returns the barman list as list of User.
Source code incounter/models.py
@cached_property\ndef barmen_list(self) -> list[User]:\n \"\"\"Returns the barman list as list of User.\"\"\"\n return [\n p.user for p in self.permanencies.filter(end=None).select_related(\"user\")\n ]\n
"},{"location":"reference/eboutic/models/#eboutic.models.Counter.get_random_barman","title":"get_random_barman()
","text":"Return a random user being currently a barman.
Source code incounter/models.py
def get_random_barman(self) -> User:\n \"\"\"Return a random user being currently a barman.\"\"\"\n return random.choice(self.barmen_list)\n
"},{"location":"reference/eboutic/models/#eboutic.models.Counter.update_activity","title":"update_activity()
","text":"Update the barman activity to prevent timeout.
Source code incounter/models.py
def update_activity(self) -> None:\n \"\"\"Update the barman activity to prevent timeout.\"\"\"\n self.permanencies.filter(end=None).update(activity=timezone.now())\n
"},{"location":"reference/eboutic/models/#eboutic.models.Counter.can_refill","title":"can_refill()
","text":"Show if the counter authorize the refilling with physic money.
Source code incounter/models.py
def can_refill(self) -> bool:\n \"\"\"Show if the counter authorize the refilling with physic money.\"\"\"\n if self.type != \"BAR\":\n return False\n if self.id in SITH_COUNTER_OFFICES:\n # If the counter is either 'AE' or 'BdF', refills are authorized\n return True\n # at least one of the barmen is in the AE board\n ae = Club.objects.get(unix_name=SITH_MAIN_CLUB[\"unix_name\"])\n return any(ae.get_membership_for(barman) for barman in self.barmen_list)\n
"},{"location":"reference/eboutic/models/#eboutic.models.Counter.get_top_barmen","title":"get_top_barmen()
","text":"Return a QuerySet querying the office hours stats of all the barmen of all time of this counter, ordered by descending number of hours.
Each element of the QuerySet corresponds to a barman and has the following datacounter/models.py
def get_top_barmen(self) -> QuerySet:\n \"\"\"Return a QuerySet querying the office hours stats of all the barmen of all time\n of this counter, ordered by descending number of hours.\n\n Each element of the QuerySet corresponds to a barman and has the following data :\n - the full name (first name + last name) of the barman\n - the nickname of the barman\n - the promo of the barman\n - the total number of office hours the barman did attend\n \"\"\"\n return (\n self.permanencies.exclude(end=None)\n .annotate(\n name=Concat(F(\"user__first_name\"), Value(\" \"), F(\"user__last_name\"))\n )\n .annotate(nickname=F(\"user__nick_name\"))\n .annotate(promo=F(\"user__promo\"))\n .values(\"user\", \"name\", \"nickname\", \"promo\")\n .annotate(perm_sum=Sum(F(\"end\") - F(\"start\")))\n .exclude(perm_sum=None)\n .order_by(\"-perm_sum\")\n )\n
"},{"location":"reference/eboutic/models/#eboutic.models.Counter.get_top_customers","title":"get_top_customers(since=None)
","text":"Return a QuerySet querying the money spent by customers of this counter since the specified date, ordered by descending amount of money spent.
Each element of the QuerySet corresponds to a customer and has the following data :
Parameters:
Name Type Description Defaultsince
datetime | date | None
timestamp from which to perform the calculation
None
Source code in counter/models.py
def get_top_customers(self, since: datetime | date | None = None) -> QuerySet:\n \"\"\"Return a QuerySet querying the money spent by customers of this counter\n since the specified date, ordered by descending amount of money spent.\n\n Each element of the QuerySet corresponds to a customer and has the following data :\n\n - the full name (first name + last name) of the customer\n - the nickname of the customer\n - the amount of money spent by the customer\n\n Args:\n since: timestamp from which to perform the calculation\n \"\"\"\n if since is None:\n since = get_start_of_semester()\n if isinstance(since, date):\n since = datetime(since.year, since.month, since.day, tzinfo=tz.utc)\n return (\n self.sellings.filter(date__gte=since)\n .annotate(\n name=Concat(\n F(\"customer__user__first_name\"),\n Value(\" \"),\n F(\"customer__user__last_name\"),\n )\n )\n .annotate(nickname=F(\"customer__user__nick_name\"))\n .annotate(promo=F(\"customer__user__promo\"))\n .annotate(user=F(\"customer__user\"))\n .values(\"user\", \"promo\", \"name\", \"nickname\")\n .annotate(\n selling_sum=Sum(\n F(\"unit_price\") * F(\"quantity\"), output_field=CurrencyField()\n )\n )\n .filter(selling_sum__gt=0)\n .order_by(\"-selling_sum\")\n )\n
"},{"location":"reference/eboutic/models/#eboutic.models.Counter.get_total_sales","title":"get_total_sales(since=None)
","text":"Compute and return the total turnover of this counter since the given date.
By default, the date is the start of the current semester.
Parameters:
Name Type Description Defaultsince
datetime | date | None
timestamp from which to perform the calculation
None
Returns:
Type DescriptionCurrencyField
Total revenue earned at this counter.
Source code incounter/models.py
def get_total_sales(self, since: datetime | date | None = None) -> CurrencyField:\n \"\"\"Compute and return the total turnover of this counter since the given date.\n\n By default, the date is the start of the current semester.\n\n Args:\n since: timestamp from which to perform the calculation\n\n Returns:\n Total revenue earned at this counter.\n \"\"\"\n if since is None:\n since = get_start_of_semester()\n if isinstance(since, date):\n since = datetime(since.year, since.month, since.day, tzinfo=tz.utc)\n return self.sellings.filter(date__gte=since).aggregate(\n total=Sum(\n F(\"quantity\") * F(\"unit_price\"),\n default=0,\n output_field=CurrencyField(),\n )\n )[\"total\"]\n
"},{"location":"reference/eboutic/models/#eboutic.models.Customer","title":"Customer
","text":" Bases: Model
Customer data of a User.
It adds some basic customers' information, such as the account ID, and is used by other accounting classes as reference to the customer, rather than using User.
"},{"location":"reference/eboutic/models/#eboutic.models.Customer.can_buy","title":"can_buy: bool
property
","text":"Check if whether this customer has the right to purchase any item.
This must be not confused with the Product.can_be_sold_to(user) method as the present method returns an information about a customer whereas the other tells something about the relation between a User (not a Customer, don't mix them) and a Product.
"},{"location":"reference/eboutic/models/#eboutic.models.Customer.save","title":"save(*args, allow_negative=False, is_selling=False, **kwargs)
","text":"is_selling : tell if the current action is a selling allow_negative : ignored if not a selling. Allow a selling to put the account in negative Those two parameters avoid blocking the save method of a customer if his account is negative.
Source code incounter/models.py
def save(self, *args, allow_negative=False, is_selling=False, **kwargs):\n \"\"\"is_selling : tell if the current action is a selling\n allow_negative : ignored if not a selling. Allow a selling to put the account in negative\n Those two parameters avoid blocking the save method of a customer if his account is negative.\n \"\"\"\n if self.amount < 0 and (is_selling and not allow_negative):\n raise ValidationError(_(\"Not enough money\"))\n super().save(*args, **kwargs)\n
"},{"location":"reference/eboutic/models/#eboutic.models.Customer.get_or_create","title":"get_or_create(user)
classmethod
","text":"Work in pretty much the same way as the usual get_or_create method, but with the default field replaced by some under the hood.
If the user has an account, return it as is. Else create a new account with no money on it and a new unique account id
Example : ::
user = User.objects.get(pk=1)\naccount, created = Customer.get_or_create(user)\nif created:\n print(f\"created a new account with id {account.id}\")\nelse:\n print(f\"user has already an account, with {account.id} \u20ac on it\"\n
Source code in counter/models.py
@classmethod\ndef get_or_create(cls, user: User) -> Tuple[Customer, bool]:\n \"\"\"Work in pretty much the same way as the usual get_or_create method,\n but with the default field replaced by some under the hood.\n\n If the user has an account, return it as is.\n Else create a new account with no money on it and a new unique account id\n\n Example : ::\n\n user = User.objects.get(pk=1)\n account, created = Customer.get_or_create(user)\n if created:\n print(f\"created a new account with id {account.id}\")\n else:\n print(f\"user has already an account, with {account.id} \u20ac on it\"\n \"\"\"\n if hasattr(user, \"customer\"):\n return user.customer, False\n\n # account_id are always a number with a letter appended\n account_id = (\n Customer.objects.order_by(Length(\"account_id\"), \"account_id\")\n .values(\"account_id\")\n .last()\n )\n if account_id is None:\n # legacy from the old site\n account = cls.objects.create(user=user, account_id=\"1504a\")\n return account, True\n\n account_id = account_id[\"account_id\"]\n account_num = int(account_id[:-1])\n while Customer.objects.filter(account_id=account_id).exists():\n # when entering the first iteration, we are using an already existing account id\n # so the loop should always execute at least one time\n account_num += 1\n account_id = f\"{account_num}{random.choice(string.ascii_lowercase)}\"\n\n account = cls.objects.create(user=user, account_id=account_id)\n return account, True\n
"},{"location":"reference/eboutic/models/#eboutic.models.Product","title":"Product
","text":" Bases: Model
A product, with all its related information.
"},{"location":"reference/eboutic/models/#eboutic.models.Product.is_owned_by","title":"is_owned_by(user)
","text":"Method to see if that object can be edited by the given user.
Source code incounter/models.py
def is_owned_by(self, user):\n \"\"\"Method to see if that object can be edited by the given user.\"\"\"\n if user.is_anonymous:\n return False\n return user.is_in_group(\n pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID\n ) or user.is_in_group(pk=settings.SITH_GROUP_COUNTER_ADMIN_ID)\n
"},{"location":"reference/eboutic/models/#eboutic.models.Product.can_be_sold_to","title":"can_be_sold_to(user)
","text":"Check if whether the user given in parameter has the right to buy this product or not.
This must be not confused with the Customer.can_buy() method as the present method returns an information about the relation between a User and a Product, whereas the other tells something about a Customer (and not a user, they are not the same model).
Returns:
Type Descriptionbool
True if the user can buy this product else False
WarningThis 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 :
user = User.objects.get(username=\"foobar\")\nproducts = [\n p\n for p in Product.objects.prefetch_related(\"buying_groups\")\n if p.can_be_sold_to(user)\n]\n
Source code in counter/models.py
def can_be_sold_to(self, user: User) -> bool:\n \"\"\"Check if whether the user given in parameter has the right to buy\n this product or not.\n\n This must be not confused with the Customer.can_buy()\n method as the present method returns an information\n about the relation between a User and a Product,\n whereas the other tells something about a Customer\n (and not a user, they are not the same model).\n\n Returns:\n True if the user can buy this product else False\n\n Warning:\n This performs a db query, thus you can quickly have\n a N+1 queries problem if you call it in a loop.\n Hopefully, you can avoid that if you prefetch the buying_groups :\n\n ```python\n user = User.objects.get(username=\"foobar\")\n products = [\n p\n for p in Product.objects.prefetch_related(\"buying_groups\")\n if p.can_be_sold_to(user)\n ]\n ```\n \"\"\"\n buying_groups = list(self.buying_groups.all())\n if not buying_groups:\n return True\n return any(user.is_in_group(pk=group.id) for group in buying_groups)\n
"},{"location":"reference/eboutic/models/#eboutic.models.Refilling","title":"Refilling
","text":" Bases: Model
Handle the refilling.
"},{"location":"reference/eboutic/models/#eboutic.models.Selling","title":"Selling
","text":" Bases: Model
Handle the sellings.
"},{"location":"reference/eboutic/models/#eboutic.models.Selling.save","title":"save(*args, allow_negative=False, **kwargs)
","text":"allow_negative : Allow this selling to use more money than available for this user.
Source code incounter/models.py
def save(self, *args, allow_negative=False, **kwargs):\n \"\"\"allow_negative : Allow this selling to use more money than available for this user.\"\"\"\n if not self.date:\n self.date = timezone.now()\n self.full_clean()\n if not self.is_validated:\n self.customer.amount -= self.quantity * self.unit_price\n self.customer.save(allow_negative=allow_negative, is_selling=True)\n self.is_validated = True\n user = self.customer.user\n if user.was_subscribed:\n if (\n self.product\n and self.product.id == settings.SITH_PRODUCT_SUBSCRIPTION_ONE_SEMESTER\n ):\n sub = Subscription(\n member=user,\n subscription_type=\"un-semestre\",\n payment_method=\"EBOUTIC\",\n location=\"EBOUTIC\",\n )\n sub.subscription_start = Subscription.compute_start()\n sub.subscription_start = Subscription.compute_start(\n duration=settings.SITH_SUBSCRIPTIONS[sub.subscription_type][\n \"duration\"\n ]\n )\n sub.subscription_end = Subscription.compute_end(\n duration=settings.SITH_SUBSCRIPTIONS[sub.subscription_type][\n \"duration\"\n ],\n start=sub.subscription_start,\n )\n sub.save()\n elif (\n self.product\n and self.product.id == settings.SITH_PRODUCT_SUBSCRIPTION_TWO_SEMESTERS\n ):\n sub = Subscription(\n member=user,\n subscription_type=\"deux-semestres\",\n payment_method=\"EBOUTIC\",\n location=\"EBOUTIC\",\n )\n sub.subscription_start = Subscription.compute_start()\n sub.subscription_start = Subscription.compute_start(\n duration=settings.SITH_SUBSCRIPTIONS[sub.subscription_type][\n \"duration\"\n ]\n )\n sub.subscription_end = Subscription.compute_end(\n duration=settings.SITH_SUBSCRIPTIONS[sub.subscription_type][\n \"duration\"\n ],\n start=sub.subscription_start,\n )\n sub.save()\n if user.preferences.notify_on_click:\n Notification(\n user=user,\n url=reverse(\n \"core:user_account_detail\",\n kwargs={\n \"user_id\": user.id,\n \"year\": self.date.year,\n \"month\": self.date.month,\n },\n ),\n param=\"%d x %s\" % (self.quantity, self.label),\n type=\"SELLING\",\n ).save()\n super().save(*args, **kwargs)\n if hasattr(self.product, \"eticket\"):\n self.send_mail_customer()\n
"},{"location":"reference/eboutic/models/#eboutic.models.Basket","title":"Basket
","text":" Bases: Model
Basket is built when the user connects to an eboutic page.
"},{"location":"reference/eboutic/models/#eboutic.models.Basket.from_session","title":"from_session(session)
classmethod
","text":"The basket stored in the session object, if it exists.
Source code ineboutic/models.py
@classmethod\ndef from_session(cls, session) -> Basket | None:\n \"\"\"The basket stored in the session object, if it exists.\"\"\"\n if \"basket_id\" in session:\n return cls.objects.filter(id=session[\"basket_id\"]).first()\n return None\n
"},{"location":"reference/eboutic/models/#eboutic.models.Basket.generate_sales","title":"generate_sales(counter, seller, payment_method)
","text":"Generate a list of sold items corresponding to the items of this basket WITHOUT saving them NOR deleting the basket.
Examplecounter = Counter.objects.get(name=\"Eboutic\")\nsales = basket.generate_sales(counter, \"SITH_ACCOUNT\")\n# here the basket is in the same state as before the method call\n\nwith transaction.atomic():\n for sale in sales:\n sale.save()\n basket.delete()\n # all the basket items are deleted by the on_delete=CASCADE relation\n # thus only the sales remain\n
Source code in eboutic/models.py
def generate_sales(self, counter, seller: User, payment_method: str):\n \"\"\"Generate a list of sold items corresponding to the items\n of this basket WITHOUT saving them NOR deleting the basket.\n\n Example:\n ```python\n counter = Counter.objects.get(name=\"Eboutic\")\n sales = basket.generate_sales(counter, \"SITH_ACCOUNT\")\n # here the basket is in the same state as before the method call\n\n with transaction.atomic():\n for sale in sales:\n sale.save()\n basket.delete()\n # all the basket items are deleted by the on_delete=CASCADE relation\n # thus only the sales remain\n ```\n \"\"\"\n # I must proceed with two distinct requests instead of\n # only one with a join because the AbstractBaseItem model has been\n # poorly designed. If you refactor the model, please refactor this too.\n items = self.items.order_by(\"product_id\")\n ids = [item.product_id for item in items]\n products = Product.objects.filter(id__in=ids).order_by(\"id\")\n # items and products are sorted in the same order\n sales = []\n for item, product in zip(items, products):\n sales.append(\n Selling(\n label=product.name,\n counter=counter,\n club=product.club,\n product=product,\n seller=seller,\n customer=self.user.customer,\n unit_price=item.product_unit_price,\n quantity=item.quantity,\n payment_method=payment_method,\n )\n )\n return sales\n
"},{"location":"reference/eboutic/models/#eboutic.models.InvoiceQueryset","title":"InvoiceQueryset
","text":" Bases: QuerySet
annotate_total()
","text":"Annotate the queryset with the total amount of each invoice.
The total amount is the sum of (product_unit_price * quantity) for all items related to the invoice.
Source code ineboutic/models.py
def annotate_total(self) -> Self:\n \"\"\"Annotate the queryset with the total amount of each invoice.\n\n The total amount is the sum of (product_unit_price * quantity)\n for all items related to the invoice.\n \"\"\"\n # aggregates within subqueries require a little bit of black magic,\n # but hopefully, django gives a comprehensive documentation for that :\n # https://docs.djangoproject.com/en/stable/ref/models/expressions/#using-aggregates-within-a-subquery-expression\n return self.annotate(\n total=Subquery(\n InvoiceItem.objects.filter(invoice_id=OuterRef(\"pk\"))\n .values(\"invoice_id\")\n .annotate(total=Sum(F(\"product_unit_price\") * F(\"quantity\")))\n .values(\"total\")\n )\n )\n
"},{"location":"reference/eboutic/models/#eboutic.models.Invoice","title":"Invoice
","text":" Bases: Model
Invoices are generated once the payment has been validated.
"},{"location":"reference/eboutic/models/#eboutic.models.AbstractBaseItem","title":"AbstractBaseItem
","text":" Bases: Model
BasketItem
","text":" Bases: AbstractBaseItem
from_product(product, quantity, basket)
classmethod
","text":"Create a BasketItem with the same characteristics as the product passed in parameters, with the specified quantity.
Warningthe basket field is not filled, so you must set it yourself before saving the model.
Source code ineboutic/models.py
@classmethod\ndef from_product(cls, product: Product, quantity: int, basket: Basket):\n \"\"\"Create a BasketItem with the same characteristics as the\n product passed in parameters, with the specified quantity.\n\n Warning:\n the basket field is not filled, so you must set\n it yourself before saving the model.\n \"\"\"\n return cls(\n basket=basket,\n product_id=product.id,\n product_name=product.name,\n type_id=product.product_type_id,\n quantity=quantity,\n product_unit_price=product.selling_price,\n )\n
"},{"location":"reference/eboutic/models/#eboutic.models.InvoiceItem","title":"InvoiceItem
","text":" Bases: AbstractBaseItem
get_eboutic_products(user)
","text":"Source code in eboutic/models.py
def get_eboutic_products(user: User) -> list[Product]:\n products = (\n Counter.objects.get(type=\"EBOUTIC\")\n .products.filter(product_type__isnull=False)\n .filter(archived=False)\n .filter(limit_age__lte=user.age)\n .annotate(priority=F(\"product_type__priority\"))\n .annotate(category=F(\"product_type__name\"))\n .annotate(category_comment=F(\"product_type__comment\"))\n .prefetch_related(\"buying_groups\") # <-- used in `Product.can_be_sold_to`\n )\n return [p for p in products if p.can_be_sold_to(user)]\n
"},{"location":"reference/eboutic/views/","title":"Views","text":""},{"location":"reference/eboutic/views/#eboutic.views.PurchaseItemList","title":"PurchaseItemList = TypeAdapter(list[PurchaseItemSchema])
module-attribute
","text":""},{"location":"reference/eboutic/views/#eboutic.views.BillingInfoForm","title":"BillingInfoForm
","text":" Bases: ModelForm
Counter
","text":" Bases: Model
gen_token()
","text":"Generate a new token for this counter.
Source code incounter/models.py
def gen_token(self) -> None:\n \"\"\"Generate a new token for this counter.\"\"\"\n self.token = \"\".join(\n random.choice(string.ascii_letters + string.digits) for _ in range(30)\n )\n self.save()\n
"},{"location":"reference/eboutic/views/#eboutic.views.Counter.barmen_list","title":"barmen_list()
","text":"Returns the barman list as list of User.
Source code incounter/models.py
@cached_property\ndef barmen_list(self) -> list[User]:\n \"\"\"Returns the barman list as list of User.\"\"\"\n return [\n p.user for p in self.permanencies.filter(end=None).select_related(\"user\")\n ]\n
"},{"location":"reference/eboutic/views/#eboutic.views.Counter.get_random_barman","title":"get_random_barman()
","text":"Return a random user being currently a barman.
Source code incounter/models.py
def get_random_barman(self) -> User:\n \"\"\"Return a random user being currently a barman.\"\"\"\n return random.choice(self.barmen_list)\n
"},{"location":"reference/eboutic/views/#eboutic.views.Counter.update_activity","title":"update_activity()
","text":"Update the barman activity to prevent timeout.
Source code incounter/models.py
def update_activity(self) -> None:\n \"\"\"Update the barman activity to prevent timeout.\"\"\"\n self.permanencies.filter(end=None).update(activity=timezone.now())\n
"},{"location":"reference/eboutic/views/#eboutic.views.Counter.can_refill","title":"can_refill()
","text":"Show if the counter authorize the refilling with physic money.
Source code incounter/models.py
def can_refill(self) -> bool:\n \"\"\"Show if the counter authorize the refilling with physic money.\"\"\"\n if self.type != \"BAR\":\n return False\n if self.id in SITH_COUNTER_OFFICES:\n # If the counter is either 'AE' or 'BdF', refills are authorized\n return True\n # at least one of the barmen is in the AE board\n ae = Club.objects.get(unix_name=SITH_MAIN_CLUB[\"unix_name\"])\n return any(ae.get_membership_for(barman) for barman in self.barmen_list)\n
"},{"location":"reference/eboutic/views/#eboutic.views.Counter.get_top_barmen","title":"get_top_barmen()
","text":"Return a QuerySet querying the office hours stats of all the barmen of all time of this counter, ordered by descending number of hours.
Each element of the QuerySet corresponds to a barman and has the following datacounter/models.py
def get_top_barmen(self) -> QuerySet:\n \"\"\"Return a QuerySet querying the office hours stats of all the barmen of all time\n of this counter, ordered by descending number of hours.\n\n Each element of the QuerySet corresponds to a barman and has the following data :\n - the full name (first name + last name) of the barman\n - the nickname of the barman\n - the promo of the barman\n - the total number of office hours the barman did attend\n \"\"\"\n return (\n self.permanencies.exclude(end=None)\n .annotate(\n name=Concat(F(\"user__first_name\"), Value(\" \"), F(\"user__last_name\"))\n )\n .annotate(nickname=F(\"user__nick_name\"))\n .annotate(promo=F(\"user__promo\"))\n .values(\"user\", \"name\", \"nickname\", \"promo\")\n .annotate(perm_sum=Sum(F(\"end\") - F(\"start\")))\n .exclude(perm_sum=None)\n .order_by(\"-perm_sum\")\n )\n
"},{"location":"reference/eboutic/views/#eboutic.views.Counter.get_top_customers","title":"get_top_customers(since=None)
","text":"Return a QuerySet querying the money spent by customers of this counter since the specified date, ordered by descending amount of money spent.
Each element of the QuerySet corresponds to a customer and has the following data :
Parameters:
Name Type Description Defaultsince
datetime | date | None
timestamp from which to perform the calculation
None
Source code in counter/models.py
def get_top_customers(self, since: datetime | date | None = None) -> QuerySet:\n \"\"\"Return a QuerySet querying the money spent by customers of this counter\n since the specified date, ordered by descending amount of money spent.\n\n Each element of the QuerySet corresponds to a customer and has the following data :\n\n - the full name (first name + last name) of the customer\n - the nickname of the customer\n - the amount of money spent by the customer\n\n Args:\n since: timestamp from which to perform the calculation\n \"\"\"\n if since is None:\n since = get_start_of_semester()\n if isinstance(since, date):\n since = datetime(since.year, since.month, since.day, tzinfo=tz.utc)\n return (\n self.sellings.filter(date__gte=since)\n .annotate(\n name=Concat(\n F(\"customer__user__first_name\"),\n Value(\" \"),\n F(\"customer__user__last_name\"),\n )\n )\n .annotate(nickname=F(\"customer__user__nick_name\"))\n .annotate(promo=F(\"customer__user__promo\"))\n .annotate(user=F(\"customer__user\"))\n .values(\"user\", \"promo\", \"name\", \"nickname\")\n .annotate(\n selling_sum=Sum(\n F(\"unit_price\") * F(\"quantity\"), output_field=CurrencyField()\n )\n )\n .filter(selling_sum__gt=0)\n .order_by(\"-selling_sum\")\n )\n
"},{"location":"reference/eboutic/views/#eboutic.views.Counter.get_total_sales","title":"get_total_sales(since=None)
","text":"Compute and return the total turnover of this counter since the given date.
By default, the date is the start of the current semester.
Parameters:
Name Type Description Defaultsince
datetime | date | None
timestamp from which to perform the calculation
None
Returns:
Type DescriptionCurrencyField
Total revenue earned at this counter.
Source code incounter/models.py
def get_total_sales(self, since: datetime | date | None = None) -> CurrencyField:\n \"\"\"Compute and return the total turnover of this counter since the given date.\n\n By default, the date is the start of the current semester.\n\n Args:\n since: timestamp from which to perform the calculation\n\n Returns:\n Total revenue earned at this counter.\n \"\"\"\n if since is None:\n since = get_start_of_semester()\n if isinstance(since, date):\n since = datetime(since.year, since.month, since.day, tzinfo=tz.utc)\n return self.sellings.filter(date__gte=since).aggregate(\n total=Sum(\n F(\"quantity\") * F(\"unit_price\"),\n default=0,\n output_field=CurrencyField(),\n )\n )[\"total\"]\n
"},{"location":"reference/eboutic/views/#eboutic.views.Customer","title":"Customer
","text":" Bases: Model
Customer data of a User.
It adds some basic customers' information, such as the account ID, and is used by other accounting classes as reference to the customer, rather than using User.
"},{"location":"reference/eboutic/views/#eboutic.views.Customer.can_buy","title":"can_buy: bool
property
","text":"Check if whether this customer has the right to purchase any item.
This must be not confused with the Product.can_be_sold_to(user) method as the present method returns an information about a customer whereas the other tells something about the relation between a User (not a Customer, don't mix them) and a Product.
"},{"location":"reference/eboutic/views/#eboutic.views.Customer.save","title":"save(*args, allow_negative=False, is_selling=False, **kwargs)
","text":"is_selling : tell if the current action is a selling allow_negative : ignored if not a selling. Allow a selling to put the account in negative Those two parameters avoid blocking the save method of a customer if his account is negative.
Source code incounter/models.py
def save(self, *args, allow_negative=False, is_selling=False, **kwargs):\n \"\"\"is_selling : tell if the current action is a selling\n allow_negative : ignored if not a selling. Allow a selling to put the account in negative\n Those two parameters avoid blocking the save method of a customer if his account is negative.\n \"\"\"\n if self.amount < 0 and (is_selling and not allow_negative):\n raise ValidationError(_(\"Not enough money\"))\n super().save(*args, **kwargs)\n
"},{"location":"reference/eboutic/views/#eboutic.views.Customer.get_or_create","title":"get_or_create(user)
classmethod
","text":"Work in pretty much the same way as the usual get_or_create method, but with the default field replaced by some under the hood.
If the user has an account, return it as is. Else create a new account with no money on it and a new unique account id
Example : ::
user = User.objects.get(pk=1)\naccount, created = Customer.get_or_create(user)\nif created:\n print(f\"created a new account with id {account.id}\")\nelse:\n print(f\"user has already an account, with {account.id} \u20ac on it\"\n
Source code in counter/models.py
@classmethod\ndef get_or_create(cls, user: User) -> Tuple[Customer, bool]:\n \"\"\"Work in pretty much the same way as the usual get_or_create method,\n but with the default field replaced by some under the hood.\n\n If the user has an account, return it as is.\n Else create a new account with no money on it and a new unique account id\n\n Example : ::\n\n user = User.objects.get(pk=1)\n account, created = Customer.get_or_create(user)\n if created:\n print(f\"created a new account with id {account.id}\")\n else:\n print(f\"user has already an account, with {account.id} \u20ac on it\"\n \"\"\"\n if hasattr(user, \"customer\"):\n return user.customer, False\n\n # account_id are always a number with a letter appended\n account_id = (\n Customer.objects.order_by(Length(\"account_id\"), \"account_id\")\n .values(\"account_id\")\n .last()\n )\n if account_id is None:\n # legacy from the old site\n account = cls.objects.create(user=user, account_id=\"1504a\")\n return account, True\n\n account_id = account_id[\"account_id\"]\n account_num = int(account_id[:-1])\n while Customer.objects.filter(account_id=account_id).exists():\n # when entering the first iteration, we are using an already existing account id\n # so the loop should always execute at least one time\n account_num += 1\n account_id = f\"{account_num}{random.choice(string.ascii_lowercase)}\"\n\n account = cls.objects.create(user=user, account_id=account_id)\n return account, True\n
"},{"location":"reference/eboutic/views/#eboutic.views.Product","title":"Product
","text":" Bases: Model
A product, with all its related information.
"},{"location":"reference/eboutic/views/#eboutic.views.Product.is_owned_by","title":"is_owned_by(user)
","text":"Method to see if that object can be edited by the given user.
Source code incounter/models.py
def is_owned_by(self, user):\n \"\"\"Method to see if that object can be edited by the given user.\"\"\"\n if user.is_anonymous:\n return False\n return user.is_in_group(\n pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID\n ) or user.is_in_group(pk=settings.SITH_GROUP_COUNTER_ADMIN_ID)\n
"},{"location":"reference/eboutic/views/#eboutic.views.Product.can_be_sold_to","title":"can_be_sold_to(user)
","text":"Check if whether the user given in parameter has the right to buy this product or not.
This must be not confused with the Customer.can_buy() method as the present method returns an information about the relation between a User and a Product, whereas the other tells something about a Customer (and not a user, they are not the same model).
Returns:
Type Descriptionbool
True if the user can buy this product else False
WarningThis 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 :
user = User.objects.get(username=\"foobar\")\nproducts = [\n p\n for p in Product.objects.prefetch_related(\"buying_groups\")\n if p.can_be_sold_to(user)\n]\n
Source code in counter/models.py
def can_be_sold_to(self, user: User) -> bool:\n \"\"\"Check if whether the user given in parameter has the right to buy\n this product or not.\n\n This must be not confused with the Customer.can_buy()\n method as the present method returns an information\n about the relation between a User and a Product,\n whereas the other tells something about a Customer\n (and not a user, they are not the same model).\n\n Returns:\n True if the user can buy this product else False\n\n Warning:\n This performs a db query, thus you can quickly have\n a N+1 queries problem if you call it in a loop.\n Hopefully, you can avoid that if you prefetch the buying_groups :\n\n ```python\n user = User.objects.get(username=\"foobar\")\n products = [\n p\n for p in Product.objects.prefetch_related(\"buying_groups\")\n if p.can_be_sold_to(user)\n ]\n ```\n \"\"\"\n buying_groups = list(self.buying_groups.all())\n if not buying_groups:\n return True\n return any(user.is_in_group(pk=group.id) for group in buying_groups)\n
"},{"location":"reference/eboutic/views/#eboutic.views.BasketForm","title":"BasketForm(request)
","text":"Class intended to perform checks on the request sended to the server when the user submits his basket from /eboutic/.
Because it must check an unknown number of fields, coming from a cookie and needing some databases checks to be performed, inheriting from forms.Form or using formset would have been likely to end in a big ball of wibbly-wobbly hacky stuff. 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.
Examples:
::
def my_view(request):\n form = BasketForm(request)\n form.clean()\n if form.is_valid():\n # perform operations\n else:\n errors = form.get_error_messages()\n\n # return the cookie that was in the request, but with all\n # incorrects elements removed\n cookie = form.get_cleaned_cookie()\n
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.
eboutic/forms.py
def __init__(self, request: HttpRequest):\n self.user = request.user\n self.cookies = request.COOKIES\n self.error_messages = set()\n self.correct_items = []\n
"},{"location":"reference/eboutic/views/#eboutic.views.BasketForm.clean","title":"clean()
","text":"Perform all the checks, but return nothing. To know if the form is valid, the is_valid()
method must be used.
[{'id': <int>, 'quantity': <int>, 'name': <str>, 'unit_price': <float>}, ...]
. The order of the fields in each object does not mattereboutic/forms.py
def clean(self) -> None:\n \"\"\"Perform all the checks, but return nothing.\n To know if the form is valid, the `is_valid()` method must be used.\n\n The form shall be considered as valid if it meets all the following conditions :\n - it contains a \"basket_items\" key in the cookies of the request given in the constructor\n - this cookie is a list of objects formatted this way : `[{'id': <int>, 'quantity': <int>,\n 'name': <str>, 'unit_price': <float>}, ...]`. The order of the fields in each object does not matter\n - all the ids are positive integers\n - all the ids refer to products available in the EBOUTIC\n - all the ids refer to products the user is allowed to buy\n - all the quantities are positive integers\n \"\"\"\n try:\n basket = PurchaseItemList.validate_json(\n unquote(self.cookies.get(\"basket_items\", \"[]\"))\n )\n except ValidationError:\n self.error_messages.add(_(\"The request was badly formatted.\"))\n return\n if len(basket) == 0:\n self.error_messages.add(_(\"Your basket is empty.\"))\n return\n existing_ids = {product.id for product in get_eboutic_products(self.user)}\n for item in basket:\n # check a product with this id does exist\n if item.product_id in existing_ids:\n self.correct_items.append(item)\n else:\n self.error_messages.add(\n _(\n \"%(name)s : this product does not exist or may no longer be available.\"\n )\n % {\"name\": item.name}\n )\n continue\n
"},{"location":"reference/eboutic/views/#eboutic.views.BasketForm.is_valid","title":"is_valid()
","text":"Return True if the form is correct else False.
If the clean()
method has not been called beforehand, call it.
eboutic/forms.py
def is_valid(self) -> bool:\n \"\"\"Return True if the form is correct else False.\n\n If the `clean()` method has not been called beforehand, call it.\n \"\"\"\n if not self.error_messages and not self.correct_items:\n self.clean()\n return not self.error_messages\n
"},{"location":"reference/eboutic/views/#eboutic.views.Basket","title":"Basket
","text":" Bases: Model
Basket is built when the user connects to an eboutic page.
"},{"location":"reference/eboutic/views/#eboutic.views.Basket.from_session","title":"from_session(session)
classmethod
","text":"The basket stored in the session object, if it exists.
Source code ineboutic/models.py
@classmethod\ndef from_session(cls, session) -> Basket | None:\n \"\"\"The basket stored in the session object, if it exists.\"\"\"\n if \"basket_id\" in session:\n return cls.objects.filter(id=session[\"basket_id\"]).first()\n return None\n
"},{"location":"reference/eboutic/views/#eboutic.views.Basket.generate_sales","title":"generate_sales(counter, seller, payment_method)
","text":"Generate a list of sold items corresponding to the items of this basket WITHOUT saving them NOR deleting the basket.
Examplecounter = Counter.objects.get(name=\"Eboutic\")\nsales = basket.generate_sales(counter, \"SITH_ACCOUNT\")\n# here the basket is in the same state as before the method call\n\nwith transaction.atomic():\n for sale in sales:\n sale.save()\n basket.delete()\n # all the basket items are deleted by the on_delete=CASCADE relation\n # thus only the sales remain\n
Source code in eboutic/models.py
def generate_sales(self, counter, seller: User, payment_method: str):\n \"\"\"Generate a list of sold items corresponding to the items\n of this basket WITHOUT saving them NOR deleting the basket.\n\n Example:\n ```python\n counter = Counter.objects.get(name=\"Eboutic\")\n sales = basket.generate_sales(counter, \"SITH_ACCOUNT\")\n # here the basket is in the same state as before the method call\n\n with transaction.atomic():\n for sale in sales:\n sale.save()\n basket.delete()\n # all the basket items are deleted by the on_delete=CASCADE relation\n # thus only the sales remain\n ```\n \"\"\"\n # I must proceed with two distinct requests instead of\n # only one with a join because the AbstractBaseItem model has been\n # poorly designed. If you refactor the model, please refactor this too.\n items = self.items.order_by(\"product_id\")\n ids = [item.product_id for item in items]\n products = Product.objects.filter(id__in=ids).order_by(\"id\")\n # items and products are sorted in the same order\n sales = []\n for item, product in zip(items, products):\n sales.append(\n Selling(\n label=product.name,\n counter=counter,\n club=product.club,\n product=product,\n seller=seller,\n customer=self.user.customer,\n unit_price=item.product_unit_price,\n quantity=item.quantity,\n payment_method=payment_method,\n )\n )\n return sales\n
"},{"location":"reference/eboutic/views/#eboutic.views.BasketItem","title":"BasketItem
","text":" Bases: AbstractBaseItem
from_product(product, quantity, basket)
classmethod
","text":"Create a BasketItem with the same characteristics as the product passed in parameters, with the specified quantity.
Warningthe basket field is not filled, so you must set it yourself before saving the model.
Source code ineboutic/models.py
@classmethod\ndef from_product(cls, product: Product, quantity: int, basket: Basket):\n \"\"\"Create a BasketItem with the same characteristics as the\n product passed in parameters, with the specified quantity.\n\n Warning:\n the basket field is not filled, so you must set\n it yourself before saving the model.\n \"\"\"\n return cls(\n basket=basket,\n product_id=product.id,\n product_name=product.name,\n type_id=product.product_type_id,\n quantity=quantity,\n product_unit_price=product.selling_price,\n )\n
"},{"location":"reference/eboutic/views/#eboutic.views.Invoice","title":"Invoice
","text":" Bases: Model
Invoices are generated once the payment has been validated.
"},{"location":"reference/eboutic/views/#eboutic.views.InvoiceItem","title":"InvoiceItem
","text":" Bases: AbstractBaseItem
PurchaseItemSchema
","text":" Bases: Schema
BillingInfoState
","text":" Bases: Enum
EbouticCommand
","text":" Bases: LoginRequiredMixin
, TemplateView
EtransactionAutoAnswer
","text":" Bases: View
get_eboutic_products(user)
","text":"Source code in eboutic/models.py
def get_eboutic_products(user: User) -> list[Product]:\n products = (\n Counter.objects.get(type=\"EBOUTIC\")\n .products.filter(product_type__isnull=False)\n .filter(archived=False)\n .filter(limit_age__lte=user.age)\n .annotate(priority=F(\"product_type__priority\"))\n .annotate(category=F(\"product_type__name\"))\n .annotate(category_comment=F(\"product_type__comment\"))\n .prefetch_related(\"buying_groups\") # <-- used in `Product.can_be_sold_to`\n )\n return [p for p in products if p.can_be_sold_to(user)]\n
"},{"location":"reference/eboutic/views/#eboutic.views.eboutic_main","title":"eboutic_main(request)
","text":"Main view of the eboutic application.
Return an Http response whose content is of type text/html. The latter represents the page from which a user can see the catalogue of products that he can buy and fill his shopping cart.
The purchasable products are those of the eboutic which belong to a category of products of a product category (orphan products are inaccessible).
If the session contains a key-value pair that associates \"errors\" with a list of strings, this pair is removed from the session and its value displayed to the user when the page is rendered.
Source code ineboutic/views.py
@login_required\n@require_GET\ndef eboutic_main(request: HttpRequest) -> HttpResponse:\n \"\"\"Main view of the eboutic application.\n\n Return an Http response whose content is of type text/html.\n The latter represents the page from which a user can see\n the catalogue of products that he can buy and fill\n his shopping cart.\n\n The purchasable products are those of the eboutic which\n belong to a category of products of a product category\n (orphan products are inaccessible).\n\n If the session contains a key-value pair that associates \"errors\"\n with a list of strings, this pair is removed from the session\n and its value displayed to the user when the page is rendered.\n \"\"\"\n errors = request.session.pop(\"errors\", None)\n products = get_eboutic_products(request.user)\n context = {\n \"errors\": errors,\n \"products\": products,\n \"customer_amount\": request.user.account_balance,\n }\n return render(request, \"eboutic/eboutic_main.jinja\", context)\n
"},{"location":"reference/eboutic/views/#eboutic.views.payment_result","title":"payment_result(request, result)
","text":"Source code in eboutic/views.py
@require_GET\n@login_required\ndef payment_result(request, result: str) -> HttpResponse:\n context = {\"success\": result == \"success\"}\n return render(request, \"eboutic/eboutic_payment_result.jinja\", context)\n
"},{"location":"reference/eboutic/views/#eboutic.views.e_transaction_data","title":"e_transaction_data(request)
","text":"Source code in eboutic/views.py
@login_required\n@require_GET\ndef e_transaction_data(request):\n basket = Basket.from_session(request.session)\n if basket is None:\n return HttpResponse(status=404, content=json.dumps({\"data\": []}))\n data = basket.get_e_transaction_data()\n data = {\"data\": [{\"key\": key, \"value\": val} for key, val in data]}\n return HttpResponse(status=200, content=json.dumps(data))\n
"},{"location":"reference/eboutic/views/#eboutic.views.pay_with_sith","title":"pay_with_sith(request)
","text":"Source code in eboutic/views.py
@login_required\n@require_POST\ndef pay_with_sith(request):\n basket = Basket.from_session(request.session)\n refilling = settings.SITH_COUNTER_PRODUCTTYPE_REFILLING\n if basket is None or basket.items.filter(type_id=refilling).exists():\n return redirect(\"eboutic:main\")\n c = Customer.objects.filter(user__id=basket.user_id).first()\n if c is None:\n return redirect(\"eboutic:main\")\n if c.amount < basket.total:\n res = redirect(\"eboutic:payment_result\", \"failure\")\n res.delete_cookie(\"basket_items\", \"/eboutic\")\n return res\n eboutic = Counter.objects.get(type=\"EBOUTIC\")\n sales = basket.generate_sales(eboutic, c.user, \"SITH_ACCOUNT\")\n try:\n with transaction.atomic():\n # Selling.save has some important business logic in it.\n # Do not bulk_create this\n for sale in sales:\n sale.save()\n basket.delete()\n request.session.pop(\"basket_id\", None)\n res = redirect(\"eboutic:payment_result\", \"success\")\n except DatabaseError as e:\n with sentry_sdk.push_scope() as scope:\n scope.user = {\"username\": request.user.username}\n scope.set_extra(\"someVariable\", e.__repr__())\n sentry_sdk.capture_message(\n f\"Erreur le {datetime.now()} dans eboutic.pay_with_sith\"\n )\n res = redirect(\"eboutic:payment_result\", \"failure\")\n res.delete_cookie(\"basket_items\", \"/eboutic\")\n return res\n
"},{"location":"reference/election/models/","title":"Models","text":""},{"location":"reference/election/models/#election.models.Group","title":"Group
","text":" Bases: Group
Implement both RealGroups and Meta groups.
Groups are sorted by their is_meta property
"},{"location":"reference/election/models/#election.models.User","title":"User
","text":" Bases: AbstractBaseUser
Defines the base user class, useable in every app.
This is almost the same as the auth module AbstractUser since it inherits from it, but some fields are required, and the username is generated automatically with the name of the user (see generate_username()).
Added field: nick_name, date_of_birth Required fields: email, first_name, last_name, date_of_birth
"},{"location":"reference/election/models/#election.models.User.cached_groups","title":"cached_groups: list[Group]
property
","text":"Get the list of groups this user is in.
The result is cached for the default duration (should be 5 minutes)
Returns: A list of all the groups this user is in.
"},{"location":"reference/election/models/#election.models.User.is_in_group","title":"is_in_group(*, pk=None, name=None)
","text":"Check if this user is in the given group. Either a group id or a group name must be provided. If both are passed, only the id will be considered.
The group will be fetched using the given parameter. If no group is found, return False. If a group is found, check if this user is in the latter.
Returns:
Type Descriptionbool
True if the user is the group, else False
Source code incore/models.py
def is_in_group(self, *, pk: int | None = None, name: str | None = None) -> bool:\n \"\"\"Check if this user is in the given group.\n Either a group id or a group name must be provided.\n If both are passed, only the id will be considered.\n\n The group will be fetched using the given parameter.\n If no group is found, return False.\n If a group is found, check if this user is in the latter.\n\n Returns:\n True if the user is the group, else False\n \"\"\"\n if pk is not None:\n group: Optional[Group] = get_group(pk=pk)\n elif name is not None:\n group: Optional[Group] = get_group(name=name)\n else:\n raise ValueError(\"You must either provide the id or the name of the group\")\n if group is None:\n return False\n if group.id == settings.SITH_GROUP_PUBLIC_ID:\n return True\n if group.id == settings.SITH_GROUP_SUBSCRIBERS_ID:\n return self.is_subscribed\n if group.id == settings.SITH_GROUP_OLD_SUBSCRIBERS_ID:\n return self.was_subscribed\n if group.id == settings.SITH_GROUP_ROOT_ID:\n return self.is_root\n if group.is_meta:\n # check if this group is associated with a club\n group.__class__ = MetaGroup\n club = group.associated_club\n if club is None:\n return False\n membership = club.get_membership_for(self)\n if membership is None:\n return False\n if group.name.endswith(settings.SITH_MEMBER_SUFFIX):\n return True\n return membership.role > settings.SITH_MAXIMUM_FREE_ROLE\n return group in self.cached_groups\n
"},{"location":"reference/election/models/#election.models.User.age","title":"age()
","text":"Return the age this user has the day the method is called. If the user has not filled his age, return 0.
Source code incore/models.py
@cached_property\ndef age(self) -> int:\n \"\"\"Return the age this user has the day the method is called.\n If the user has not filled his age, return 0.\n \"\"\"\n if self.date_of_birth is None:\n return 0\n today = timezone.now()\n age = today.year - self.date_of_birth.year\n # remove a year if this year's birthday is yet to come\n age -= (today.month, today.day) < (\n self.date_of_birth.month,\n self.date_of_birth.day,\n )\n return age\n
"},{"location":"reference/election/models/#election.models.User.get_full_name","title":"get_full_name()
","text":"Returns the first_name plus the last_name, with a space in between.
Source code incore/models.py
def get_full_name(self):\n \"\"\"Returns the first_name plus the last_name, with a space in between.\"\"\"\n full_name = \"%s %s\" % (self.first_name, self.last_name)\n return full_name.strip()\n
"},{"location":"reference/election/models/#election.models.User.get_short_name","title":"get_short_name()
","text":"Returns the short name for the user.
Source code incore/models.py
def get_short_name(self):\n \"\"\"Returns the short name for the user.\"\"\"\n if self.nick_name:\n return self.nick_name\n return self.first_name + \" \" + self.last_name\n
"},{"location":"reference/election/models/#election.models.User.get_display_name","title":"get_display_name()
","text":"Returns the display name of the user.
A nickname if possible, otherwise, the full name.
Source code incore/models.py
def get_display_name(self) -> str:\n \"\"\"Returns the display name of the user.\n\n A nickname if possible, otherwise, the full name.\n \"\"\"\n if self.nick_name:\n return \"%s (%s)\" % (self.get_full_name(), self.nick_name)\n return self.get_full_name()\n
"},{"location":"reference/election/models/#election.models.User.get_age","title":"get_age()
","text":"Returns the age.
Source code incore/models.py
def get_age(self):\n \"\"\"Returns the age.\"\"\"\n today = timezone.now()\n born = self.date_of_birth\n return (\n today.year - born.year - ((today.month, today.day) < (born.month, born.day))\n )\n
"},{"location":"reference/election/models/#election.models.User.get_family","title":"get_family(godfathers_depth=4, godchildren_depth=4)
","text":"Get the family of the user, with the given depth.
Parameters:
Name Type Description Defaultgodfathers_depth
NonNegativeInt
The number of generations of godfathers to fetch
4
godchildren_depth
NonNegativeInt
The number of generations of godchildren to fetch
4
Returns:
Type Descriptionset[through]
A list of family relationships in this user's family
Source code incore/models.py
def get_family(\n self,\n godfathers_depth: NonNegativeInt = 4,\n godchildren_depth: NonNegativeInt = 4,\n) -> set[User.godfathers.through]:\n \"\"\"Get the family of the user, with the given depth.\n\n Args:\n godfathers_depth: The number of generations of godfathers to fetch\n godchildren_depth: The number of generations of godchildren to fetch\n\n Returns:\n A list of family relationships in this user's family\n \"\"\"\n res = []\n for depth, key, reverse_key in [\n (godfathers_depth, \"from_user_id\", \"to_user_id\"),\n (godchildren_depth, \"to_user_id\", \"from_user_id\"),\n ]:\n if depth == 0:\n continue\n links = list(User.godfathers.through.objects.filter(**{key: self.id}))\n res.extend(links)\n for _ in range(1, depth): # noqa: F402 we don't care about gettext here\n ids = [getattr(c, reverse_key) for c in links]\n links = list(\n User.godfathers.through.objects.filter(\n **{f\"{key}__in\": ids}\n ).exclude(id__in=[r.id for r in res])\n )\n if not links:\n break\n res.extend(links)\n return set(res)\n
"},{"location":"reference/election/models/#election.models.User.email_user","title":"email_user(subject, message, from_email=None, **kwargs)
","text":"Sends an email to this User.
Source code incore/models.py
def email_user(self, subject, message, from_email=None, **kwargs):\n \"\"\"Sends an email to this User.\"\"\"\n if from_email is None:\n from_email = settings.DEFAULT_FROM_EMAIL\n send_mail(subject, message, from_email, [self.email], **kwargs)\n
"},{"location":"reference/election/models/#election.models.User.generate_username","title":"generate_username()
","text":"Generates a unique username based on the first and last names.
For example: Guy Carlier gives gcarlier, and gcarlier1 if the first one exists.
Returns:
Type Descriptionstr
The generated username.
Source code incore/models.py
def generate_username(self) -> str:\n \"\"\"Generates a unique username based on the first and last names.\n\n For example: Guy Carlier gives gcarlier, and gcarlier1 if the first one exists.\n\n Returns:\n The generated username.\n \"\"\"\n\n def remove_accents(data):\n return \"\".join(\n x\n for x in unicodedata.normalize(\"NFKD\", data)\n if unicodedata.category(x)[0] == \"L\"\n ).lower()\n\n user_name = (\n remove_accents(self.first_name[0] + self.last_name)\n .encode(\"ascii\", \"ignore\")\n .decode(\"utf-8\")\n )\n un_set = [u.username for u in User.objects.all()]\n if user_name in un_set:\n i = 1\n while user_name + str(i) in un_set:\n i += 1\n user_name += str(i)\n self.username = user_name\n return user_name\n
"},{"location":"reference/election/models/#election.models.User.is_owner","title":"is_owner(obj)
","text":"Determine if the object is owned by the user.
Source code incore/models.py
def is_owner(self, obj):\n \"\"\"Determine if the object is owned by the user.\"\"\"\n if hasattr(obj, \"is_owned_by\") and obj.is_owned_by(self):\n return True\n if hasattr(obj, \"owner_group\") and self.is_in_group(pk=obj.owner_group.id):\n return True\n return self.is_root\n
"},{"location":"reference/election/models/#election.models.User.can_edit","title":"can_edit(obj)
","text":"Determine if the object can be edited by the user.
Source code incore/models.py
def can_edit(self, obj):\n \"\"\"Determine if the object can be edited by the user.\"\"\"\n if hasattr(obj, \"can_be_edited_by\") and obj.can_be_edited_by(self):\n return True\n if hasattr(obj, \"edit_groups\"):\n for pk in obj.edit_groups.values_list(\"pk\", flat=True):\n if self.is_in_group(pk=pk):\n return True\n if isinstance(obj, User) and obj == self:\n return True\n return self.is_owner(obj)\n
"},{"location":"reference/election/models/#election.models.User.can_view","title":"can_view(obj)
","text":"Determine if the object can be viewed by the user.
Source code incore/models.py
def can_view(self, obj):\n \"\"\"Determine if the object can be viewed by the user.\"\"\"\n if hasattr(obj, \"can_be_viewed_by\") and obj.can_be_viewed_by(self):\n return True\n if hasattr(obj, \"view_groups\"):\n for pk in obj.view_groups.values_list(\"pk\", flat=True):\n if self.is_in_group(pk=pk):\n return True\n return self.can_edit(obj)\n
"},{"location":"reference/election/models/#election.models.User.clubs_with_rights","title":"clubs_with_rights()
","text":"The list of clubs where the user has rights
Source code incore/models.py
@cached_property\ndef clubs_with_rights(self) -> list[Club]:\n \"\"\"The list of clubs where the user has rights\"\"\"\n memberships = self.memberships.ongoing().board().select_related(\"club\")\n return [m.club for m in memberships]\n
"},{"location":"reference/election/models/#election.models.Election","title":"Election
","text":" Bases: Model
This class allows to create a new election.
"},{"location":"reference/election/models/#election.models.Role","title":"Role
","text":" Bases: OrderedModel
This class allows to create a new role avaliable for a candidature.
"},{"location":"reference/election/models/#election.models.ElectionList","title":"ElectionList
","text":" Bases: Model
To allow per list vote.
"},{"location":"reference/election/models/#election.models.Candidature","title":"Candidature
","text":" Bases: Model
This class is a component of responsability.
"},{"location":"reference/election/models/#election.models.Vote","title":"Vote
","text":" Bases: Model
This class allows to vote for candidates.
"},{"location":"reference/election/views/","title":"Views","text":""},{"location":"reference/election/views/#election.views.CanCreateMixin","title":"CanCreateMixin
","text":" Bases: View
Protect any child view that would create an object.
Raises:
Type DescriptionPermissionDenied
If the user has not the necessary permission to create the object of the view.
"},{"location":"reference/election/views/#election.views.CanEditMixin","title":"CanEditMixin
","text":" Bases: GenericContentPermissionMixinBuilder
Ensure the user has permission to edit this view's object.
Raises:
Type DescriptionPermissionDenied
if the user cannot edit this view's object.
"},{"location":"reference/election/views/#election.views.CanViewMixin","title":"CanViewMixin
","text":" Bases: GenericContentPermissionMixinBuilder
Ensure the user has permission to view this view's object.
Raises:
Type DescriptionPermissionDenied
if the user cannot edit this view's object.
"},{"location":"reference/election/views/#election.views.Candidature","title":"Candidature
","text":" Bases: Model
This class is a component of responsability.
"},{"location":"reference/election/views/#election.views.Election","title":"Election
","text":" Bases: Model
This class allows to create a new election.
"},{"location":"reference/election/views/#election.views.ElectionList","title":"ElectionList
","text":" Bases: Model
To allow per list vote.
"},{"location":"reference/election/views/#election.views.Role","title":"Role
","text":" Bases: OrderedModel
This class allows to create a new role avaliable for a candidature.
"},{"location":"reference/election/views/#election.views.Vote","title":"Vote
","text":" Bases: Model
This class allows to vote for candidates.
"},{"location":"reference/election/views/#election.views.User","title":"User
","text":" Bases: AbstractBaseUser
Defines the base user class, useable in every app.
This is almost the same as the auth module AbstractUser since it inherits from it, but some fields are required, and the username is generated automatically with the name of the user (see generate_username()).
Added field: nick_name, date_of_birth Required fields: email, first_name, last_name, date_of_birth
"},{"location":"reference/election/views/#election.views.User.cached_groups","title":"cached_groups: list[Group]
property
","text":"Get the list of groups this user is in.
The result is cached for the default duration (should be 5 minutes)
Returns: A list of all the groups this user is in.
"},{"location":"reference/election/views/#election.views.User.is_in_group","title":"is_in_group(*, pk=None, name=None)
","text":"Check if this user is in the given group. Either a group id or a group name must be provided. If both are passed, only the id will be considered.
The group will be fetched using the given parameter. If no group is found, return False. If a group is found, check if this user is in the latter.
Returns:
Type Descriptionbool
True if the user is the group, else False
Source code incore/models.py
def is_in_group(self, *, pk: int | None = None, name: str | None = None) -> bool:\n \"\"\"Check if this user is in the given group.\n Either a group id or a group name must be provided.\n If both are passed, only the id will be considered.\n\n The group will be fetched using the given parameter.\n If no group is found, return False.\n If a group is found, check if this user is in the latter.\n\n Returns:\n True if the user is the group, else False\n \"\"\"\n if pk is not None:\n group: Optional[Group] = get_group(pk=pk)\n elif name is not None:\n group: Optional[Group] = get_group(name=name)\n else:\n raise ValueError(\"You must either provide the id or the name of the group\")\n if group is None:\n return False\n if group.id == settings.SITH_GROUP_PUBLIC_ID:\n return True\n if group.id == settings.SITH_GROUP_SUBSCRIBERS_ID:\n return self.is_subscribed\n if group.id == settings.SITH_GROUP_OLD_SUBSCRIBERS_ID:\n return self.was_subscribed\n if group.id == settings.SITH_GROUP_ROOT_ID:\n return self.is_root\n if group.is_meta:\n # check if this group is associated with a club\n group.__class__ = MetaGroup\n club = group.associated_club\n if club is None:\n return False\n membership = club.get_membership_for(self)\n if membership is None:\n return False\n if group.name.endswith(settings.SITH_MEMBER_SUFFIX):\n return True\n return membership.role > settings.SITH_MAXIMUM_FREE_ROLE\n return group in self.cached_groups\n
"},{"location":"reference/election/views/#election.views.User.age","title":"age()
","text":"Return the age this user has the day the method is called. If the user has not filled his age, return 0.
Source code incore/models.py
@cached_property\ndef age(self) -> int:\n \"\"\"Return the age this user has the day the method is called.\n If the user has not filled his age, return 0.\n \"\"\"\n if self.date_of_birth is None:\n return 0\n today = timezone.now()\n age = today.year - self.date_of_birth.year\n # remove a year if this year's birthday is yet to come\n age -= (today.month, today.day) < (\n self.date_of_birth.month,\n self.date_of_birth.day,\n )\n return age\n
"},{"location":"reference/election/views/#election.views.User.get_full_name","title":"get_full_name()
","text":"Returns the first_name plus the last_name, with a space in between.
Source code incore/models.py
def get_full_name(self):\n \"\"\"Returns the first_name plus the last_name, with a space in between.\"\"\"\n full_name = \"%s %s\" % (self.first_name, self.last_name)\n return full_name.strip()\n
"},{"location":"reference/election/views/#election.views.User.get_short_name","title":"get_short_name()
","text":"Returns the short name for the user.
Source code incore/models.py
def get_short_name(self):\n \"\"\"Returns the short name for the user.\"\"\"\n if self.nick_name:\n return self.nick_name\n return self.first_name + \" \" + self.last_name\n
"},{"location":"reference/election/views/#election.views.User.get_display_name","title":"get_display_name()
","text":"Returns the display name of the user.
A nickname if possible, otherwise, the full name.
Source code incore/models.py
def get_display_name(self) -> str:\n \"\"\"Returns the display name of the user.\n\n A nickname if possible, otherwise, the full name.\n \"\"\"\n if self.nick_name:\n return \"%s (%s)\" % (self.get_full_name(), self.nick_name)\n return self.get_full_name()\n
"},{"location":"reference/election/views/#election.views.User.get_age","title":"get_age()
","text":"Returns the age.
Source code incore/models.py
def get_age(self):\n \"\"\"Returns the age.\"\"\"\n today = timezone.now()\n born = self.date_of_birth\n return (\n today.year - born.year - ((today.month, today.day) < (born.month, born.day))\n )\n
"},{"location":"reference/election/views/#election.views.User.get_family","title":"get_family(godfathers_depth=4, godchildren_depth=4)
","text":"Get the family of the user, with the given depth.
Parameters:
Name Type Description Defaultgodfathers_depth
NonNegativeInt
The number of generations of godfathers to fetch
4
godchildren_depth
NonNegativeInt
The number of generations of godchildren to fetch
4
Returns:
Type Descriptionset[through]
A list of family relationships in this user's family
Source code incore/models.py
def get_family(\n self,\n godfathers_depth: NonNegativeInt = 4,\n godchildren_depth: NonNegativeInt = 4,\n) -> set[User.godfathers.through]:\n \"\"\"Get the family of the user, with the given depth.\n\n Args:\n godfathers_depth: The number of generations of godfathers to fetch\n godchildren_depth: The number of generations of godchildren to fetch\n\n Returns:\n A list of family relationships in this user's family\n \"\"\"\n res = []\n for depth, key, reverse_key in [\n (godfathers_depth, \"from_user_id\", \"to_user_id\"),\n (godchildren_depth, \"to_user_id\", \"from_user_id\"),\n ]:\n if depth == 0:\n continue\n links = list(User.godfathers.through.objects.filter(**{key: self.id}))\n res.extend(links)\n for _ in range(1, depth): # noqa: F402 we don't care about gettext here\n ids = [getattr(c, reverse_key) for c in links]\n links = list(\n User.godfathers.through.objects.filter(\n **{f\"{key}__in\": ids}\n ).exclude(id__in=[r.id for r in res])\n )\n if not links:\n break\n res.extend(links)\n return set(res)\n
"},{"location":"reference/election/views/#election.views.User.email_user","title":"email_user(subject, message, from_email=None, **kwargs)
","text":"Sends an email to this User.
Source code incore/models.py
def email_user(self, subject, message, from_email=None, **kwargs):\n \"\"\"Sends an email to this User.\"\"\"\n if from_email is None:\n from_email = settings.DEFAULT_FROM_EMAIL\n send_mail(subject, message, from_email, [self.email], **kwargs)\n
"},{"location":"reference/election/views/#election.views.User.generate_username","title":"generate_username()
","text":"Generates a unique username based on the first and last names.
For example: Guy Carlier gives gcarlier, and gcarlier1 if the first one exists.
Returns:
Type Descriptionstr
The generated username.
Source code incore/models.py
def generate_username(self) -> str:\n \"\"\"Generates a unique username based on the first and last names.\n\n For example: Guy Carlier gives gcarlier, and gcarlier1 if the first one exists.\n\n Returns:\n The generated username.\n \"\"\"\n\n def remove_accents(data):\n return \"\".join(\n x\n for x in unicodedata.normalize(\"NFKD\", data)\n if unicodedata.category(x)[0] == \"L\"\n ).lower()\n\n user_name = (\n remove_accents(self.first_name[0] + self.last_name)\n .encode(\"ascii\", \"ignore\")\n .decode(\"utf-8\")\n )\n un_set = [u.username for u in User.objects.all()]\n if user_name in un_set:\n i = 1\n while user_name + str(i) in un_set:\n i += 1\n user_name += str(i)\n self.username = user_name\n return user_name\n
"},{"location":"reference/election/views/#election.views.User.is_owner","title":"is_owner(obj)
","text":"Determine if the object is owned by the user.
Source code incore/models.py
def is_owner(self, obj):\n \"\"\"Determine if the object is owned by the user.\"\"\"\n if hasattr(obj, \"is_owned_by\") and obj.is_owned_by(self):\n return True\n if hasattr(obj, \"owner_group\") and self.is_in_group(pk=obj.owner_group.id):\n return True\n return self.is_root\n
"},{"location":"reference/election/views/#election.views.User.can_edit","title":"can_edit(obj)
","text":"Determine if the object can be edited by the user.
Source code incore/models.py
def can_edit(self, obj):\n \"\"\"Determine if the object can be edited by the user.\"\"\"\n if hasattr(obj, \"can_be_edited_by\") and obj.can_be_edited_by(self):\n return True\n if hasattr(obj, \"edit_groups\"):\n for pk in obj.edit_groups.values_list(\"pk\", flat=True):\n if self.is_in_group(pk=pk):\n return True\n if isinstance(obj, User) and obj == self:\n return True\n return self.is_owner(obj)\n
"},{"location":"reference/election/views/#election.views.User.can_view","title":"can_view(obj)
","text":"Determine if the object can be viewed by the user.
Source code incore/models.py
def can_view(self, obj):\n \"\"\"Determine if the object can be viewed by the user.\"\"\"\n if hasattr(obj, \"can_be_viewed_by\") and obj.can_be_viewed_by(self):\n return True\n if hasattr(obj, \"view_groups\"):\n for pk in obj.view_groups.values_list(\"pk\", flat=True):\n if self.is_in_group(pk=pk):\n return True\n return self.can_edit(obj)\n
"},{"location":"reference/election/views/#election.views.User.clubs_with_rights","title":"clubs_with_rights()
","text":"The list of clubs where the user has rights
Source code incore/models.py
@cached_property\ndef clubs_with_rights(self) -> list[Club]:\n \"\"\"The list of clubs where the user has rights\"\"\"\n memberships = self.memberships.ongoing().board().select_related(\"club\")\n return [m.club for m in memberships]\n
"},{"location":"reference/election/views/#election.views.LimitedCheckboxField","title":"LimitedCheckboxField(queryset, max_choice, **kwargs)
","text":" Bases: ModelMultipleChoiceField
A ModelMultipleChoiceField
, with a max limit of selectable inputs.
election/views.py
def __init__(self, queryset, max_choice, **kwargs):\n self.max_choice = max_choice\n super().__init__(queryset, **kwargs)\n
"},{"location":"reference/election/views/#election.views.CandidateForm","title":"CandidateForm(*args, **kwargs)
","text":" Bases: ModelForm
Form to candidate.
Source code inelection/views.py
def __init__(self, *args, **kwargs):\n election_id = kwargs.pop(\"election_id\", None)\n can_edit = kwargs.pop(\"can_edit\", False)\n super().__init__(*args, **kwargs)\n if election_id:\n self.fields[\"role\"].queryset = Role.objects.filter(\n election__id=election_id\n ).all()\n self.fields[\"election_list\"].queryset = ElectionList.objects.filter(\n election__id=election_id\n ).all()\n if not can_edit:\n self.fields[\"user\"].widget = forms.HiddenInput()\n
"},{"location":"reference/election/views/#election.views.VoteForm","title":"VoteForm(election, user, *args, **kwargs)
","text":" Bases: Form
election/views.py
def __init__(self, election, user, *args, **kwargs):\n super().__init__(*args, **kwargs)\n if not election.has_voted(user):\n for role in election.roles.all():\n cand = role.candidatures\n if role.max_choice > 1:\n self.fields[role.title] = LimitedCheckboxField(\n cand, role.max_choice, required=False\n )\n else:\n self.fields[role.title] = forms.ModelChoiceField(\n cand,\n required=False,\n widget=forms.RadioSelect(),\n empty_label=_(\"Blank vote\"),\n )\n
"},{"location":"reference/election/views/#election.views.RoleForm","title":"RoleForm(*args, **kwargs)
","text":" Bases: ModelForm
Form for creating a role.
Source code inelection/views.py
def __init__(self, *args, **kwargs):\n election_id = kwargs.pop(\"election_id\", None)\n super().__init__(*args, **kwargs)\n if election_id:\n self.fields[\"election\"].queryset = Election.objects.filter(\n id=election_id\n ).all()\n
"},{"location":"reference/election/views/#election.views.ElectionListForm","title":"ElectionListForm(*args, **kwargs)
","text":" Bases: ModelForm
election/views.py
def __init__(self, *args, **kwargs):\n election_id = kwargs.pop(\"election_id\", None)\n super().__init__(*args, **kwargs)\n if election_id:\n self.fields[\"election\"].queryset = Election.objects.filter(\n id=election_id\n ).all()\n
"},{"location":"reference/election/views/#election.views.ElectionForm","title":"ElectionForm
","text":" Bases: ModelForm
ElectionsListView
","text":" Bases: CanViewMixin
, ListView
A list of all non archived elections visible.
"},{"location":"reference/election/views/#election.views.ElectionListArchivedView","title":"ElectionListArchivedView
","text":" Bases: CanViewMixin
, ListView
A list of all archived elections visible.
"},{"location":"reference/election/views/#election.views.ElectionDetailView","title":"ElectionDetailView
","text":" Bases: CanViewMixin
, DetailView
Details an election responsability by responsability.
"},{"location":"reference/election/views/#election.views.ElectionDetailView.get_context_data","title":"get_context_data(**kwargs)
","text":"Add additionnal data to the template.
Source code inelection/views.py
def get_context_data(self, **kwargs):\n \"\"\"Add additionnal data to the template.\"\"\"\n kwargs = super().get_context_data(**kwargs)\n kwargs[\"election_form\"] = VoteForm(self.object, self.request.user)\n kwargs[\"election_results\"] = self.object.results\n return kwargs\n
"},{"location":"reference/election/views/#election.views.VoteFormView","title":"VoteFormView
","text":" Bases: CanCreateMixin
, FormView
Alows users to vote.
"},{"location":"reference/election/views/#election.views.VoteFormView.form_valid","title":"form_valid(form)
","text":"Verify that the user is part in a vote group.
Source code inelection/views.py
def form_valid(self, form):\n \"\"\"Verify that the user is part in a vote group.\"\"\"\n data = form.clean()\n res = super(FormView, self).form_valid(form)\n for grp_id in self.election.vote_groups.values_list(\"pk\", flat=True):\n if self.request.user.is_in_group(pk=grp_id):\n self.vote(data)\n return res\n return res\n
"},{"location":"reference/election/views/#election.views.VoteFormView.get_context_data","title":"get_context_data(**kwargs)
","text":"Add additionnal data to the template.
Source code inelection/views.py
def get_context_data(self, **kwargs):\n \"\"\"Add additionnal data to the template.\"\"\"\n kwargs = super().get_context_data(**kwargs)\n kwargs[\"object\"] = self.election\n kwargs[\"election\"] = self.election\n kwargs[\"election_form\"] = self.get_form()\n return kwargs\n
"},{"location":"reference/election/views/#election.views.CandidatureCreateView","title":"CandidatureCreateView
","text":" Bases: CanCreateMixin
, CreateView
View dedicated to a cundidature creation.
"},{"location":"reference/election/views/#election.views.CandidatureCreateView.form_valid","title":"form_valid(form)
","text":"Verify that the selected user is in candidate group.
Source code inelection/views.py
def form_valid(self, form):\n \"\"\"Verify that the selected user is in candidate group.\"\"\"\n obj = form.instance\n obj.election = Election.objects.get(id=self.election.id)\n obj.user = obj.user if hasattr(obj, \"user\") else self.request.user\n if (obj.election.can_candidate(obj.user)) and (\n obj.user == self.request.user or self.can_edit\n ):\n return super(CreateView, self).form_valid(form)\n raise PermissionDenied\n
"},{"location":"reference/election/views/#election.views.ElectionCreateView","title":"ElectionCreateView
","text":" Bases: CanCreateMixin
, CreateView
form_valid(form)
","text":"Allow every user that had passed the dispatch to create an election.
Source code inelection/views.py
def form_valid(self, form):\n \"\"\"Allow every user that had passed the dispatch to create an election.\"\"\"\n return super(CreateView, self).form_valid(form)\n
"},{"location":"reference/election/views/#election.views.RoleCreateView","title":"RoleCreateView
","text":" Bases: CanCreateMixin
, CreateView
form_valid(form)
","text":"Verify that the user can edit properly.
Source code inelection/views.py
def form_valid(self, form):\n \"\"\"Verify that the user can edit properly.\"\"\"\n obj: Role = form.instance\n user: User = self.request.user\n if obj.election:\n for grp_id in obj.election.edit_groups.values_list(\"pk\", flat=True):\n if user.is_in_group(pk=grp_id):\n return super(CreateView, self).form_valid(form)\n raise PermissionDenied\n
"},{"location":"reference/election/views/#election.views.ElectionListCreateView","title":"ElectionListCreateView
","text":" Bases: CanCreateMixin
, CreateView
form_valid(form)
","text":"Verify that the user can vote on this election.
Source code inelection/views.py
def form_valid(self, form):\n \"\"\"Verify that the user can vote on this election.\"\"\"\n obj: ElectionList = form.instance\n user: User = self.request.user\n if obj.election:\n for grp_id in obj.election.candidature_groups.values_list(\"pk\", flat=True):\n if user.is_in_group(pk=grp_id):\n return super(CreateView, self).form_valid(form)\n for grp_id in obj.election.edit_groups.values_list(\"pk\", flat=True):\n if user.is_in_group(pk=grp_id):\n return super(CreateView, self).form_valid(form)\n raise PermissionDenied\n
"},{"location":"reference/election/views/#election.views.ElectionUpdateView","title":"ElectionUpdateView
","text":" Bases: CanEditMixin
, UpdateView
CandidatureUpdateView
","text":" Bases: CanEditMixin
, UpdateView
RoleUpdateView
","text":" Bases: CanEditMixin
, UpdateView
ElectionDeleteView
","text":" Bases: DeleteView
CandidatureDeleteView
","text":" Bases: CanEditMixin
, DeleteView
RoleDeleteView
","text":" Bases: CanEditMixin
, DeleteView
ElectionListDeleteView
","text":" Bases: CanEditMixin
, DeleteView
MESSAGE_META_ACTIONS = [('EDIT', _('Message edited by')), ('DELETE', _('Message deleted by')), ('UNDELETE', _('Message undeleted by'))]
module-attribute
","text":""},{"location":"reference/forum/models/#forum.models.Club","title":"Club
","text":" Bases: Model
The Club class, made as a tree to allow nice tidy organization.
"},{"location":"reference/forum/models/#forum.models.Club.check_loop","title":"check_loop()
","text":"Raise a validation error when a loop is found within the parent list.
Source code inclub/models.py
def check_loop(self):\n \"\"\"Raise a validation error when a loop is found within the parent list.\"\"\"\n objs = []\n cur = self\n while cur.parent is not None:\n if cur in objs:\n raise ValidationError(_(\"You can not make loops in clubs\"))\n objs.append(cur)\n cur = cur.parent\n
"},{"location":"reference/forum/models/#forum.models.Club.is_owned_by","title":"is_owned_by(user)
","text":"Method to see if that object can be super edited by the given user.
Source code inclub/models.py
def is_owned_by(self, user):\n \"\"\"Method to see if that object can be super edited by the given user.\"\"\"\n if user.is_anonymous:\n return False\n return user.is_board_member\n
"},{"location":"reference/forum/models/#forum.models.Club.can_be_edited_by","title":"can_be_edited_by(user)
","text":"Method to see if that object can be edited by the given user.
Source code inclub/models.py
def can_be_edited_by(self, user):\n \"\"\"Method to see if that object can be edited by the given user.\"\"\"\n return self.has_rights_in_club(user)\n
"},{"location":"reference/forum/models/#forum.models.Club.can_be_viewed_by","title":"can_be_viewed_by(user)
","text":"Method to see if that object can be seen by the given user.
Source code inclub/models.py
def can_be_viewed_by(self, user):\n \"\"\"Method to see if that object can be seen by the given user.\"\"\"\n sub = User.objects.filter(pk=user.pk).first()\n if sub is None:\n return False\n return sub.was_subscribed\n
"},{"location":"reference/forum/models/#forum.models.Club.get_membership_for","title":"get_membership_for(user)
","text":"Return the current membership the given user.
NoteThe result is cached.
Source code inclub/models.py
def get_membership_for(self, user: User) -> Membership | None:\n \"\"\"Return the current membership the given user.\n\n Note:\n The result is cached.\n \"\"\"\n if user.is_anonymous:\n return None\n membership = cache.get(f\"membership_{self.id}_{user.id}\")\n if membership == \"not_member\":\n return None\n if membership is None:\n membership = self.members.filter(user=user, end_date=None).first()\n if membership is None:\n cache.set(f\"membership_{self.id}_{user.id}\", \"not_member\")\n else:\n cache.set(f\"membership_{self.id}_{user.id}\", membership)\n return membership\n
"},{"location":"reference/forum/models/#forum.models.Group","title":"Group
","text":" Bases: Group
Implement both RealGroups and Meta groups.
Groups are sorted by their is_meta property
"},{"location":"reference/forum/models/#forum.models.User","title":"User
","text":" Bases: AbstractBaseUser
Defines the base user class, useable in every app.
This is almost the same as the auth module AbstractUser since it inherits from it, but some fields are required, and the username is generated automatically with the name of the user (see generate_username()).
Added field: nick_name, date_of_birth Required fields: email, first_name, last_name, date_of_birth
"},{"location":"reference/forum/models/#forum.models.User.cached_groups","title":"cached_groups: list[Group]
property
","text":"Get the list of groups this user is in.
The result is cached for the default duration (should be 5 minutes)
Returns: A list of all the groups this user is in.
"},{"location":"reference/forum/models/#forum.models.User.is_in_group","title":"is_in_group(*, pk=None, name=None)
","text":"Check if this user is in the given group. Either a group id or a group name must be provided. If both are passed, only the id will be considered.
The group will be fetched using the given parameter. If no group is found, return False. If a group is found, check if this user is in the latter.
Returns:
Type Descriptionbool
True if the user is the group, else False
Source code incore/models.py
def is_in_group(self, *, pk: int | None = None, name: str | None = None) -> bool:\n \"\"\"Check if this user is in the given group.\n Either a group id or a group name must be provided.\n If both are passed, only the id will be considered.\n\n The group will be fetched using the given parameter.\n If no group is found, return False.\n If a group is found, check if this user is in the latter.\n\n Returns:\n True if the user is the group, else False\n \"\"\"\n if pk is not None:\n group: Optional[Group] = get_group(pk=pk)\n elif name is not None:\n group: Optional[Group] = get_group(name=name)\n else:\n raise ValueError(\"You must either provide the id or the name of the group\")\n if group is None:\n return False\n if group.id == settings.SITH_GROUP_PUBLIC_ID:\n return True\n if group.id == settings.SITH_GROUP_SUBSCRIBERS_ID:\n return self.is_subscribed\n if group.id == settings.SITH_GROUP_OLD_SUBSCRIBERS_ID:\n return self.was_subscribed\n if group.id == settings.SITH_GROUP_ROOT_ID:\n return self.is_root\n if group.is_meta:\n # check if this group is associated with a club\n group.__class__ = MetaGroup\n club = group.associated_club\n if club is None:\n return False\n membership = club.get_membership_for(self)\n if membership is None:\n return False\n if group.name.endswith(settings.SITH_MEMBER_SUFFIX):\n return True\n return membership.role > settings.SITH_MAXIMUM_FREE_ROLE\n return group in self.cached_groups\n
"},{"location":"reference/forum/models/#forum.models.User.age","title":"age()
","text":"Return the age this user has the day the method is called. If the user has not filled his age, return 0.
Source code incore/models.py
@cached_property\ndef age(self) -> int:\n \"\"\"Return the age this user has the day the method is called.\n If the user has not filled his age, return 0.\n \"\"\"\n if self.date_of_birth is None:\n return 0\n today = timezone.now()\n age = today.year - self.date_of_birth.year\n # remove a year if this year's birthday is yet to come\n age -= (today.month, today.day) < (\n self.date_of_birth.month,\n self.date_of_birth.day,\n )\n return age\n
"},{"location":"reference/forum/models/#forum.models.User.get_full_name","title":"get_full_name()
","text":"Returns the first_name plus the last_name, with a space in between.
Source code incore/models.py
def get_full_name(self):\n \"\"\"Returns the first_name plus the last_name, with a space in between.\"\"\"\n full_name = \"%s %s\" % (self.first_name, self.last_name)\n return full_name.strip()\n
"},{"location":"reference/forum/models/#forum.models.User.get_short_name","title":"get_short_name()
","text":"Returns the short name for the user.
Source code incore/models.py
def get_short_name(self):\n \"\"\"Returns the short name for the user.\"\"\"\n if self.nick_name:\n return self.nick_name\n return self.first_name + \" \" + self.last_name\n
"},{"location":"reference/forum/models/#forum.models.User.get_display_name","title":"get_display_name()
","text":"Returns the display name of the user.
A nickname if possible, otherwise, the full name.
Source code incore/models.py
def get_display_name(self) -> str:\n \"\"\"Returns the display name of the user.\n\n A nickname if possible, otherwise, the full name.\n \"\"\"\n if self.nick_name:\n return \"%s (%s)\" % (self.get_full_name(), self.nick_name)\n return self.get_full_name()\n
"},{"location":"reference/forum/models/#forum.models.User.get_age","title":"get_age()
","text":"Returns the age.
Source code incore/models.py
def get_age(self):\n \"\"\"Returns the age.\"\"\"\n today = timezone.now()\n born = self.date_of_birth\n return (\n today.year - born.year - ((today.month, today.day) < (born.month, born.day))\n )\n
"},{"location":"reference/forum/models/#forum.models.User.get_family","title":"get_family(godfathers_depth=4, godchildren_depth=4)
","text":"Get the family of the user, with the given depth.
Parameters:
Name Type Description Defaultgodfathers_depth
NonNegativeInt
The number of generations of godfathers to fetch
4
godchildren_depth
NonNegativeInt
The number of generations of godchildren to fetch
4
Returns:
Type Descriptionset[through]
A list of family relationships in this user's family
Source code incore/models.py
def get_family(\n self,\n godfathers_depth: NonNegativeInt = 4,\n godchildren_depth: NonNegativeInt = 4,\n) -> set[User.godfathers.through]:\n \"\"\"Get the family of the user, with the given depth.\n\n Args:\n godfathers_depth: The number of generations of godfathers to fetch\n godchildren_depth: The number of generations of godchildren to fetch\n\n Returns:\n A list of family relationships in this user's family\n \"\"\"\n res = []\n for depth, key, reverse_key in [\n (godfathers_depth, \"from_user_id\", \"to_user_id\"),\n (godchildren_depth, \"to_user_id\", \"from_user_id\"),\n ]:\n if depth == 0:\n continue\n links = list(User.godfathers.through.objects.filter(**{key: self.id}))\n res.extend(links)\n for _ in range(1, depth): # noqa: F402 we don't care about gettext here\n ids = [getattr(c, reverse_key) for c in links]\n links = list(\n User.godfathers.through.objects.filter(\n **{f\"{key}__in\": ids}\n ).exclude(id__in=[r.id for r in res])\n )\n if not links:\n break\n res.extend(links)\n return set(res)\n
"},{"location":"reference/forum/models/#forum.models.User.email_user","title":"email_user(subject, message, from_email=None, **kwargs)
","text":"Sends an email to this User.
Source code incore/models.py
def email_user(self, subject, message, from_email=None, **kwargs):\n \"\"\"Sends an email to this User.\"\"\"\n if from_email is None:\n from_email = settings.DEFAULT_FROM_EMAIL\n send_mail(subject, message, from_email, [self.email], **kwargs)\n
"},{"location":"reference/forum/models/#forum.models.User.generate_username","title":"generate_username()
","text":"Generates a unique username based on the first and last names.
For example: Guy Carlier gives gcarlier, and gcarlier1 if the first one exists.
Returns:
Type Descriptionstr
The generated username.
Source code incore/models.py
def generate_username(self) -> str:\n \"\"\"Generates a unique username based on the first and last names.\n\n For example: Guy Carlier gives gcarlier, and gcarlier1 if the first one exists.\n\n Returns:\n The generated username.\n \"\"\"\n\n def remove_accents(data):\n return \"\".join(\n x\n for x in unicodedata.normalize(\"NFKD\", data)\n if unicodedata.category(x)[0] == \"L\"\n ).lower()\n\n user_name = (\n remove_accents(self.first_name[0] + self.last_name)\n .encode(\"ascii\", \"ignore\")\n .decode(\"utf-8\")\n )\n un_set = [u.username for u in User.objects.all()]\n if user_name in un_set:\n i = 1\n while user_name + str(i) in un_set:\n i += 1\n user_name += str(i)\n self.username = user_name\n return user_name\n
"},{"location":"reference/forum/models/#forum.models.User.is_owner","title":"is_owner(obj)
","text":"Determine if the object is owned by the user.
Source code incore/models.py
def is_owner(self, obj):\n \"\"\"Determine if the object is owned by the user.\"\"\"\n if hasattr(obj, \"is_owned_by\") and obj.is_owned_by(self):\n return True\n if hasattr(obj, \"owner_group\") and self.is_in_group(pk=obj.owner_group.id):\n return True\n return self.is_root\n
"},{"location":"reference/forum/models/#forum.models.User.can_edit","title":"can_edit(obj)
","text":"Determine if the object can be edited by the user.
Source code incore/models.py
def can_edit(self, obj):\n \"\"\"Determine if the object can be edited by the user.\"\"\"\n if hasattr(obj, \"can_be_edited_by\") and obj.can_be_edited_by(self):\n return True\n if hasattr(obj, \"edit_groups\"):\n for pk in obj.edit_groups.values_list(\"pk\", flat=True):\n if self.is_in_group(pk=pk):\n return True\n if isinstance(obj, User) and obj == self:\n return True\n return self.is_owner(obj)\n
"},{"location":"reference/forum/models/#forum.models.User.can_view","title":"can_view(obj)
","text":"Determine if the object can be viewed by the user.
Source code incore/models.py
def can_view(self, obj):\n \"\"\"Determine if the object can be viewed by the user.\"\"\"\n if hasattr(obj, \"can_be_viewed_by\") and obj.can_be_viewed_by(self):\n return True\n if hasattr(obj, \"view_groups\"):\n for pk in obj.view_groups.values_list(\"pk\", flat=True):\n if self.is_in_group(pk=pk):\n return True\n return self.can_edit(obj)\n
"},{"location":"reference/forum/models/#forum.models.User.clubs_with_rights","title":"clubs_with_rights()
","text":"The list of clubs where the user has rights
Source code incore/models.py
@cached_property\ndef clubs_with_rights(self) -> list[Club]:\n \"\"\"The list of clubs where the user has rights\"\"\"\n memberships = self.memberships.ongoing().board().select_related(\"club\")\n return [m.club for m in memberships]\n
"},{"location":"reference/forum/models/#forum.models.Forum","title":"Forum
","text":" Bases: Model
The Forum class, made as a tree to allow nice tidy organization.
owner_club allows club members to moderate there own topics edit_groups allows to put any group as a forum admin view_groups allows some groups to view a forum
"},{"location":"reference/forum/models/#forum.models.Forum.copy_rights","title":"copy_rights()
","text":"Copy, if possible, the rights of the parent folder.
Source code inforum/models.py
def copy_rights(self):\n \"\"\"Copy, if possible, the rights of the parent folder.\"\"\"\n if self.parent is not None:\n self.owner_club = self.parent.owner_club\n self.edit_groups.set(self.parent.edit_groups.all())\n self.view_groups.set(self.parent.view_groups.all())\n self.save()\n
"},{"location":"reference/forum/models/#forum.models.Forum.check_loop","title":"check_loop()
","text":"Raise a validation error when a loop is found within the parent list.
Source code inforum/models.py
def check_loop(self):\n \"\"\"Raise a validation error when a loop is found within the parent list.\"\"\"\n objs = []\n cur = self\n while cur.parent is not None:\n if cur in objs:\n raise ValidationError(_(\"You can not make loops in forums\"))\n objs.append(cur)\n cur = cur.parent\n
"},{"location":"reference/forum/models/#forum.models.ForumTopic","title":"ForumTopic
","text":" Bases: Model
ForumMessage
","text":" Bases: Model
A message in the forum (thx Cpt. Obvious.).
"},{"location":"reference/forum/models/#forum.models.ForumMessageMeta","title":"ForumMessageMeta
","text":" Bases: Model
ForumUserInfo
","text":" Bases: Model
The forum infos of a user.
This currently stores only the last date a user clicked \"Mark all as read\". However, this can be extended with lot of user preferences dedicated to a user, such as the favourite topics, the signature, and so on...
"},{"location":"reference/forum/models/#forum.models.get_default_edit_group","title":"get_default_edit_group()
","text":"Source code in forum/models.py
def get_default_edit_group():\n return [settings.SITH_GROUP_OLD_SUBSCRIBERS_ID]\n
"},{"location":"reference/forum/models/#forum.models.get_default_view_group","title":"get_default_view_group()
","text":"Source code in forum/models.py
def get_default_view_group():\n return [settings.SITH_GROUP_PUBLIC_ID]\n
"},{"location":"reference/forum/views/","title":"Views","text":""},{"location":"reference/forum/views/#forum.views.CanCreateMixin","title":"CanCreateMixin
","text":" Bases: View
Protect any child view that would create an object.
Raises:
Type DescriptionPermissionDenied
If the user has not the necessary permission to create the object of the view.
"},{"location":"reference/forum/views/#forum.views.CanEditMixin","title":"CanEditMixin
","text":" Bases: GenericContentPermissionMixinBuilder
Ensure the user has permission to edit this view's object.
Raises:
Type DescriptionPermissionDenied
if the user cannot edit this view's object.
"},{"location":"reference/forum/views/#forum.views.CanEditPropMixin","title":"CanEditPropMixin
","text":" Bases: GenericContentPermissionMixinBuilder
Ensure the user has owner permissions on the child view object.
In other word, you can make a view with this view as parent, and it will be retricted to the users that are in the object's owner_group or that pass the obj.can_be_viewed_by
test.
Raises:
Type DescriptionPermissionDenied
If the user cannot see the object
"},{"location":"reference/forum/views/#forum.views.CanViewMixin","title":"CanViewMixin
","text":" Bases: GenericContentPermissionMixinBuilder
Ensure the user has permission to view this view's object.
Raises:
Type DescriptionPermissionDenied
if the user cannot edit this view's object.
"},{"location":"reference/forum/views/#forum.views.Forum","title":"Forum
","text":" Bases: Model
The Forum class, made as a tree to allow nice tidy organization.
owner_club allows club members to moderate there own topics edit_groups allows to put any group as a forum admin view_groups allows some groups to view a forum
"},{"location":"reference/forum/views/#forum.views.Forum.copy_rights","title":"copy_rights()
","text":"Copy, if possible, the rights of the parent folder.
Source code inforum/models.py
def copy_rights(self):\n \"\"\"Copy, if possible, the rights of the parent folder.\"\"\"\n if self.parent is not None:\n self.owner_club = self.parent.owner_club\n self.edit_groups.set(self.parent.edit_groups.all())\n self.view_groups.set(self.parent.view_groups.all())\n self.save()\n
"},{"location":"reference/forum/views/#forum.views.Forum.check_loop","title":"check_loop()
","text":"Raise a validation error when a loop is found within the parent list.
Source code inforum/models.py
def check_loop(self):\n \"\"\"Raise a validation error when a loop is found within the parent list.\"\"\"\n objs = []\n cur = self\n while cur.parent is not None:\n if cur in objs:\n raise ValidationError(_(\"You can not make loops in forums\"))\n objs.append(cur)\n cur = cur.parent\n
"},{"location":"reference/forum/views/#forum.views.ForumMessage","title":"ForumMessage
","text":" Bases: Model
A message in the forum (thx Cpt. Obvious.).
"},{"location":"reference/forum/views/#forum.views.ForumMessageMeta","title":"ForumMessageMeta
","text":" Bases: Model
ForumTopic
","text":" Bases: Model
ForumSearchView
","text":" Bases: ListView
ForumMainView
","text":" Bases: ListView
ForumMarkAllAsRead
","text":" Bases: RedirectView
ForumFavoriteTopics
","text":" Bases: ListView
ForumLastUnread
","text":" Bases: ListView
ForumNameField
","text":" Bases: ModelChoiceField
ForumForm
","text":" Bases: ModelForm
ForumCreateView
","text":" Bases: CanCreateMixin
, CreateView
ForumEditForm
","text":" Bases: ForumForm
ForumEditView
","text":" Bases: CanEditPropMixin
, UpdateView
ForumDeleteView
","text":" Bases: CanEditPropMixin
, DeleteView
ForumDetailView
","text":" Bases: CanViewMixin
, DetailView
TopicForm
","text":" Bases: ModelForm
ForumTopicCreateView
","text":" Bases: CanCreateMixin
, CreateView
ForumTopicEditView
","text":" Bases: CanEditMixin
, UpdateView
ForumTopicSubscribeView
","text":" Bases: LoginRequiredMixin
, CanViewMixin
, SingleObjectMixin
, RedirectView
ForumTopicDetailView
","text":" Bases: CanViewMixin
, DetailView
ForumMessageView
","text":" Bases: SingleObjectMixin
, RedirectView
ForumMessageEditView
","text":" Bases: CanEditMixin
, UpdateView
ForumMessageDeleteView
","text":" Bases: SingleObjectMixin
, RedirectView
ForumMessageUndeleteView
","text":" Bases: SingleObjectMixin
, RedirectView
ForumMessageCreateView
","text":" Bases: CanCreateMixin
, CreateView
can_view(obj, user)
","text":"Can the user see the object.
Parameters:
Name Type Description Defaultobj
Any
Object to test for permission
requireduser
User
core.models.User to test permissions against
requiredReturns:
Type Descriptionbool
True if user is authorized to see object else False
Examples:
if not can_view(self.object ,request.user):\n raise PermissionDenied\n
Source code in core/views/__init__.py
def can_view(obj: Any, user: User) -> bool:\n \"\"\"Can the user see the object.\n\n Args:\n obj: Object to test for permission\n user: core.models.User to test permissions against\n\n Returns:\n True if user is authorized to see object else False\n\n Examples:\n ```python\n if not can_view(self.object ,request.user):\n raise PermissionDenied\n ```\n \"\"\"\n if obj is None or user.can_view(obj):\n return True\n return can_edit(obj, user)\n
"},{"location":"reference/galaxy/models/","title":"Models","text":""},{"location":"reference/galaxy/models/#galaxy.models.current_star","title":"current_star: GalaxyStar | None
property
","text":"The star of this user in the :class:Galaxy
.
Only take into account the most recent active galaxy.
Returns:
Type DescriptionGalaxyStar | None
The star of this user if there is an active Galaxy
GalaxyStar | None
and this user is a citizen of it, else None
Club
","text":" Bases: Model
The Club class, made as a tree to allow nice tidy organization.
"},{"location":"reference/galaxy/models/#galaxy.models.Club.check_loop","title":"check_loop()
","text":"Raise a validation error when a loop is found within the parent list.
Source code inclub/models.py
def check_loop(self):\n \"\"\"Raise a validation error when a loop is found within the parent list.\"\"\"\n objs = []\n cur = self\n while cur.parent is not None:\n if cur in objs:\n raise ValidationError(_(\"You can not make loops in clubs\"))\n objs.append(cur)\n cur = cur.parent\n
"},{"location":"reference/galaxy/models/#galaxy.models.Club.is_owned_by","title":"is_owned_by(user)
","text":"Method to see if that object can be super edited by the given user.
Source code inclub/models.py
def is_owned_by(self, user):\n \"\"\"Method to see if that object can be super edited by the given user.\"\"\"\n if user.is_anonymous:\n return False\n return user.is_board_member\n
"},{"location":"reference/galaxy/models/#galaxy.models.Club.can_be_edited_by","title":"can_be_edited_by(user)
","text":"Method to see if that object can be edited by the given user.
Source code inclub/models.py
def can_be_edited_by(self, user):\n \"\"\"Method to see if that object can be edited by the given user.\"\"\"\n return self.has_rights_in_club(user)\n
"},{"location":"reference/galaxy/models/#galaxy.models.Club.can_be_viewed_by","title":"can_be_viewed_by(user)
","text":"Method to see if that object can be seen by the given user.
Source code inclub/models.py
def can_be_viewed_by(self, user):\n \"\"\"Method to see if that object can be seen by the given user.\"\"\"\n sub = User.objects.filter(pk=user.pk).first()\n if sub is None:\n return False\n return sub.was_subscribed\n
"},{"location":"reference/galaxy/models/#galaxy.models.Club.get_membership_for","title":"get_membership_for(user)
","text":"Return the current membership the given user.
NoteThe result is cached.
Source code inclub/models.py
def get_membership_for(self, user: User) -> Membership | None:\n \"\"\"Return the current membership the given user.\n\n Note:\n The result is cached.\n \"\"\"\n if user.is_anonymous:\n return None\n membership = cache.get(f\"membership_{self.id}_{user.id}\")\n if membership == \"not_member\":\n return None\n if membership is None:\n membership = self.members.filter(user=user, end_date=None).first()\n if membership is None:\n cache.set(f\"membership_{self.id}_{user.id}\", \"not_member\")\n else:\n cache.set(f\"membership_{self.id}_{user.id}\", membership)\n return membership\n
"},{"location":"reference/galaxy/models/#galaxy.models.User","title":"User
","text":" Bases: AbstractBaseUser
Defines the base user class, useable in every app.
This is almost the same as the auth module AbstractUser since it inherits from it, but some fields are required, and the username is generated automatically with the name of the user (see generate_username()).
Added field: nick_name, date_of_birth Required fields: email, first_name, last_name, date_of_birth
"},{"location":"reference/galaxy/models/#galaxy.models.User.cached_groups","title":"cached_groups: list[Group]
property
","text":"Get the list of groups this user is in.
The result is cached for the default duration (should be 5 minutes)
Returns: A list of all the groups this user is in.
"},{"location":"reference/galaxy/models/#galaxy.models.User.is_in_group","title":"is_in_group(*, pk=None, name=None)
","text":"Check if this user is in the given group. Either a group id or a group name must be provided. If both are passed, only the id will be considered.
The group will be fetched using the given parameter. If no group is found, return False. If a group is found, check if this user is in the latter.
Returns:
Type Descriptionbool
True if the user is the group, else False
Source code incore/models.py
def is_in_group(self, *, pk: int | None = None, name: str | None = None) -> bool:\n \"\"\"Check if this user is in the given group.\n Either a group id or a group name must be provided.\n If both are passed, only the id will be considered.\n\n The group will be fetched using the given parameter.\n If no group is found, return False.\n If a group is found, check if this user is in the latter.\n\n Returns:\n True if the user is the group, else False\n \"\"\"\n if pk is not None:\n group: Optional[Group] = get_group(pk=pk)\n elif name is not None:\n group: Optional[Group] = get_group(name=name)\n else:\n raise ValueError(\"You must either provide the id or the name of the group\")\n if group is None:\n return False\n if group.id == settings.SITH_GROUP_PUBLIC_ID:\n return True\n if group.id == settings.SITH_GROUP_SUBSCRIBERS_ID:\n return self.is_subscribed\n if group.id == settings.SITH_GROUP_OLD_SUBSCRIBERS_ID:\n return self.was_subscribed\n if group.id == settings.SITH_GROUP_ROOT_ID:\n return self.is_root\n if group.is_meta:\n # check if this group is associated with a club\n group.__class__ = MetaGroup\n club = group.associated_club\n if club is None:\n return False\n membership = club.get_membership_for(self)\n if membership is None:\n return False\n if group.name.endswith(settings.SITH_MEMBER_SUFFIX):\n return True\n return membership.role > settings.SITH_MAXIMUM_FREE_ROLE\n return group in self.cached_groups\n
"},{"location":"reference/galaxy/models/#galaxy.models.User.age","title":"age()
","text":"Return the age this user has the day the method is called. If the user has not filled his age, return 0.
Source code incore/models.py
@cached_property\ndef age(self) -> int:\n \"\"\"Return the age this user has the day the method is called.\n If the user has not filled his age, return 0.\n \"\"\"\n if self.date_of_birth is None:\n return 0\n today = timezone.now()\n age = today.year - self.date_of_birth.year\n # remove a year if this year's birthday is yet to come\n age -= (today.month, today.day) < (\n self.date_of_birth.month,\n self.date_of_birth.day,\n )\n return age\n
"},{"location":"reference/galaxy/models/#galaxy.models.User.get_full_name","title":"get_full_name()
","text":"Returns the first_name plus the last_name, with a space in between.
Source code incore/models.py
def get_full_name(self):\n \"\"\"Returns the first_name plus the last_name, with a space in between.\"\"\"\n full_name = \"%s %s\" % (self.first_name, self.last_name)\n return full_name.strip()\n
"},{"location":"reference/galaxy/models/#galaxy.models.User.get_short_name","title":"get_short_name()
","text":"Returns the short name for the user.
Source code incore/models.py
def get_short_name(self):\n \"\"\"Returns the short name for the user.\"\"\"\n if self.nick_name:\n return self.nick_name\n return self.first_name + \" \" + self.last_name\n
"},{"location":"reference/galaxy/models/#galaxy.models.User.get_display_name","title":"get_display_name()
","text":"Returns the display name of the user.
A nickname if possible, otherwise, the full name.
Source code incore/models.py
def get_display_name(self) -> str:\n \"\"\"Returns the display name of the user.\n\n A nickname if possible, otherwise, the full name.\n \"\"\"\n if self.nick_name:\n return \"%s (%s)\" % (self.get_full_name(), self.nick_name)\n return self.get_full_name()\n
"},{"location":"reference/galaxy/models/#galaxy.models.User.get_age","title":"get_age()
","text":"Returns the age.
Source code incore/models.py
def get_age(self):\n \"\"\"Returns the age.\"\"\"\n today = timezone.now()\n born = self.date_of_birth\n return (\n today.year - born.year - ((today.month, today.day) < (born.month, born.day))\n )\n
"},{"location":"reference/galaxy/models/#galaxy.models.User.get_family","title":"get_family(godfathers_depth=4, godchildren_depth=4)
","text":"Get the family of the user, with the given depth.
Parameters:
Name Type Description Defaultgodfathers_depth
NonNegativeInt
The number of generations of godfathers to fetch
4
godchildren_depth
NonNegativeInt
The number of generations of godchildren to fetch
4
Returns:
Type Descriptionset[through]
A list of family relationships in this user's family
Source code incore/models.py
def get_family(\n self,\n godfathers_depth: NonNegativeInt = 4,\n godchildren_depth: NonNegativeInt = 4,\n) -> set[User.godfathers.through]:\n \"\"\"Get the family of the user, with the given depth.\n\n Args:\n godfathers_depth: The number of generations of godfathers to fetch\n godchildren_depth: The number of generations of godchildren to fetch\n\n Returns:\n A list of family relationships in this user's family\n \"\"\"\n res = []\n for depth, key, reverse_key in [\n (godfathers_depth, \"from_user_id\", \"to_user_id\"),\n (godchildren_depth, \"to_user_id\", \"from_user_id\"),\n ]:\n if depth == 0:\n continue\n links = list(User.godfathers.through.objects.filter(**{key: self.id}))\n res.extend(links)\n for _ in range(1, depth): # noqa: F402 we don't care about gettext here\n ids = [getattr(c, reverse_key) for c in links]\n links = list(\n User.godfathers.through.objects.filter(\n **{f\"{key}__in\": ids}\n ).exclude(id__in=[r.id for r in res])\n )\n if not links:\n break\n res.extend(links)\n return set(res)\n
"},{"location":"reference/galaxy/models/#galaxy.models.User.email_user","title":"email_user(subject, message, from_email=None, **kwargs)
","text":"Sends an email to this User.
Source code incore/models.py
def email_user(self, subject, message, from_email=None, **kwargs):\n \"\"\"Sends an email to this User.\"\"\"\n if from_email is None:\n from_email = settings.DEFAULT_FROM_EMAIL\n send_mail(subject, message, from_email, [self.email], **kwargs)\n
"},{"location":"reference/galaxy/models/#galaxy.models.User.generate_username","title":"generate_username()
","text":"Generates a unique username based on the first and last names.
For example: Guy Carlier gives gcarlier, and gcarlier1 if the first one exists.
Returns:
Type Descriptionstr
The generated username.
Source code incore/models.py
def generate_username(self) -> str:\n \"\"\"Generates a unique username based on the first and last names.\n\n For example: Guy Carlier gives gcarlier, and gcarlier1 if the first one exists.\n\n Returns:\n The generated username.\n \"\"\"\n\n def remove_accents(data):\n return \"\".join(\n x\n for x in unicodedata.normalize(\"NFKD\", data)\n if unicodedata.category(x)[0] == \"L\"\n ).lower()\n\n user_name = (\n remove_accents(self.first_name[0] + self.last_name)\n .encode(\"ascii\", \"ignore\")\n .decode(\"utf-8\")\n )\n un_set = [u.username for u in User.objects.all()]\n if user_name in un_set:\n i = 1\n while user_name + str(i) in un_set:\n i += 1\n user_name += str(i)\n self.username = user_name\n return user_name\n
"},{"location":"reference/galaxy/models/#galaxy.models.User.is_owner","title":"is_owner(obj)
","text":"Determine if the object is owned by the user.
Source code incore/models.py
def is_owner(self, obj):\n \"\"\"Determine if the object is owned by the user.\"\"\"\n if hasattr(obj, \"is_owned_by\") and obj.is_owned_by(self):\n return True\n if hasattr(obj, \"owner_group\") and self.is_in_group(pk=obj.owner_group.id):\n return True\n return self.is_root\n
"},{"location":"reference/galaxy/models/#galaxy.models.User.can_edit","title":"can_edit(obj)
","text":"Determine if the object can be edited by the user.
Source code incore/models.py
def can_edit(self, obj):\n \"\"\"Determine if the object can be edited by the user.\"\"\"\n if hasattr(obj, \"can_be_edited_by\") and obj.can_be_edited_by(self):\n return True\n if hasattr(obj, \"edit_groups\"):\n for pk in obj.edit_groups.values_list(\"pk\", flat=True):\n if self.is_in_group(pk=pk):\n return True\n if isinstance(obj, User) and obj == self:\n return True\n return self.is_owner(obj)\n
"},{"location":"reference/galaxy/models/#galaxy.models.User.can_view","title":"can_view(obj)
","text":"Determine if the object can be viewed by the user.
Source code incore/models.py
def can_view(self, obj):\n \"\"\"Determine if the object can be viewed by the user.\"\"\"\n if hasattr(obj, \"can_be_viewed_by\") and obj.can_be_viewed_by(self):\n return True\n if hasattr(obj, \"view_groups\"):\n for pk in obj.view_groups.values_list(\"pk\", flat=True):\n if self.is_in_group(pk=pk):\n return True\n return self.can_edit(obj)\n
"},{"location":"reference/galaxy/models/#galaxy.models.User.clubs_with_rights","title":"clubs_with_rights()
","text":"The list of clubs where the user has rights
Source code incore/models.py
@cached_property\ndef clubs_with_rights(self) -> list[Club]:\n \"\"\"The list of clubs where the user has rights\"\"\"\n memberships = self.memberships.ongoing().board().select_related(\"club\")\n return [m.club for m in memberships]\n
"},{"location":"reference/galaxy/models/#galaxy.models.GalaxyStar","title":"GalaxyStar
","text":" Bases: Model
Define a star (vertex -> user) in the galaxy graph.
Store a reference to its owner citizen.
Stars are linked to each others through the :class:GalaxyLane
model.
Each GalaxyStar has a mass which push it towards the center of the galaxy. This mass is proportional to the number of pictures the owner of the star is tagged on.
"},{"location":"reference/galaxy/models/#galaxy.models.GalaxyLane","title":"GalaxyLane
","text":" Bases: Model
Define a lane (edge -> link between galaxy citizen) in the galaxy map.
Store a reference to both its ends and the distance it covers. Score details between citizen owning the stars is also stored here.
"},{"location":"reference/galaxy/models/#galaxy.models.StarDict","title":"StarDict
","text":" Bases: TypedDict
GalaxyDict
","text":" Bases: TypedDict
RelationScore
","text":" Bases: NamedTuple
Galaxy
","text":" Bases: Model
The Galaxy, a graph linking the active users between each others.
The distance between two users is given by a relation score which takes into account a few parameter like the number of pictures they are both tagged on, the time during which they were in the same clubs and whether they are in the same family.
The citizens of the Galaxy are represented by :class:GalaxyStar
and their relations by :class:GalaxyLane
.
Several galaxies can coexist. In this case, only the most recent active one shall usually be taken into account. This is useful to keep the current galaxy while generating a new one and swapping them only at the very end.
Please take into account that generating the galaxy is a very expensive operation. For this reason, try not to call the :meth:rule
method more than once a day in production.
To quickly access to the state of a galaxy, use the :attr:state
attribute.
compute_user_score(user)
classmethod
","text":"Compute an individual score for each citizen.
It will later be used by the graph algorithm to push higher scores towards the center of the galaxy.
Idea: This could be added to the computation:
galaxy/models.py
@classmethod\ndef compute_user_score(cls, user: User) -> int:\n \"\"\"Compute an individual score for each citizen.\n\n It will later be used by the graph algorithm to push\n higher scores towards the center of the galaxy.\n\n Idea: This could be added to the computation:\n\n - Forum posts\n - Picture count\n - Counter consumption\n - Barman time\n - ...\n \"\"\"\n user_score = 1\n user_score += cls.query_user_score(user)\n\n # TODO:\n # Scale that value with some magic number to accommodate to typical data\n # Really active galaxy citizen after 5 years typically have a score of about XXX\n # Citizen that were seen regularly without taking much part in organizations typically have a score of about XXX\n # Citizen that only went to a few events typically score about XXX\n user_score = int(math.log2(user_score))\n\n return user_score\n
"},{"location":"reference/galaxy/models/#galaxy.models.Galaxy.query_user_score","title":"query_user_score(user)
classmethod
","text":"Get the individual score of the given user in the galaxy.
Source code ingalaxy/models.py
@classmethod\ndef query_user_score(cls, user: User) -> int:\n \"\"\"Get the individual score of the given user in the galaxy.\"\"\"\n score_query = (\n User.objects.filter(id=user.id)\n .annotate(\n godchildren_count=Count(\"godchildren\", distinct=True)\n * cls.FAMILY_LINK_POINTS,\n godfathers_count=Count(\"godfathers\", distinct=True)\n * cls.FAMILY_LINK_POINTS,\n pictures_score=Count(\"pictures\", distinct=True) * cls.PICTURE_POINTS,\n clubs_score=Count(\"memberships\", distinct=True) * cls.CLUBS_POINTS,\n )\n .aggregate(\n score=models.Sum(\n F(\"godchildren_count\")\n + F(\"godfathers_count\")\n + F(\"pictures_score\")\n + F(\"clubs_score\")\n )\n )\n )\n return score_query.get(\"score\")\n
"},{"location":"reference/galaxy/models/#galaxy.models.Galaxy.compute_users_score","title":"compute_users_score(user1, user2)
classmethod
","text":"Compute the relationship scores of the two given users.
The computation is done with the following fields :
galaxy/models.py
@classmethod\ndef compute_users_score(cls, user1: User, user2: User) -> RelationScore:\n \"\"\"Compute the relationship scores of the two given users.\n\n The computation is done with the following fields :\n\n - family: if they have some godfather/godchild relation\n - pictures: in how many pictures are both tagged\n - clubs: during how many days they were members of the same clubs\n \"\"\"\n family = cls.compute_users_family_score(user1, user2)\n pictures = cls.compute_users_pictures_score(user1, user2)\n clubs = cls.compute_users_clubs_score(user1, user2)\n return RelationScore(family=family, pictures=pictures, clubs=clubs)\n
"},{"location":"reference/galaxy/models/#galaxy.models.Galaxy.compute_users_family_score","title":"compute_users_family_score(user1, user2)
classmethod
","text":"Compute the family score of the relation between the given users.
This takes into account mutual godfathers.
Returns:
Type Descriptionint
366 if user1 is the godfather of user2 (or vice versa) else 0
Source code ingalaxy/models.py
@classmethod\ndef compute_users_family_score(cls, user1: User, user2: User) -> int:\n \"\"\"Compute the family score of the relation between the given users.\n\n This takes into account mutual godfathers.\n\n Returns:\n 366 if user1 is the godfather of user2 (or vice versa) else 0\n \"\"\"\n link_count = User.objects.filter(\n Q(id=user1.id, godfathers=user2) | Q(id=user2.id, godfathers=user1)\n ).count()\n if link_count > 0:\n cls.logger.debug(\n f\"\\t\\t- '{user1}' and '{user2}' have {link_count} direct family link\"\n )\n return link_count * cls.FAMILY_LINK_POINTS\n
"},{"location":"reference/galaxy/models/#galaxy.models.Galaxy.compute_users_pictures_score","title":"compute_users_pictures_score(user1, user2)
classmethod
","text":"Compute the pictures score of the relation between the given users.
The pictures score is obtained by counting the number of :class:Picture
in which they have been both identified. This score is then multiplied by 2.
Returns:
Type Descriptionint
The number of pictures both users have in common, times 2
Source code ingalaxy/models.py
@classmethod\ndef compute_users_pictures_score(cls, user1: User, user2: User) -> int:\n \"\"\"Compute the pictures score of the relation between the given users.\n\n The pictures score is obtained by counting the number\n of :class:`Picture` in which they have been both identified.\n This score is then multiplied by 2.\n\n Returns:\n The number of pictures both users have in common, times 2\n \"\"\"\n picture_count = (\n Picture.objects.filter(people__user__in=(user1,))\n .filter(people__user__in=(user2,))\n .count()\n )\n if picture_count:\n cls.logger.debug(\n f\"\\t\\t- '{user1}' was pictured with '{user2}' {picture_count} times\"\n )\n return picture_count * cls.PICTURE_POINTS\n
"},{"location":"reference/galaxy/models/#galaxy.models.Galaxy.compute_users_clubs_score","title":"compute_users_clubs_score(user1, user2)
classmethod
","text":"Compute the clubs score of the relation between the given users.
The club score is obtained by counting the number of days during which the memberships (see :class:club.models.Membership
) of both users overlapped.
For example, if user1 was a member of Unitec from 01/01/2020 to 31/12/2021 (two years) and user2 was a member of the same club from 01/01/2021 to 31/12/2022 (also two years, but with an offset of one year), then their club score is 365.
Returns:
Type Descriptionint
the number of days during which both users were in the same club
Source code ingalaxy/models.py
@classmethod\ndef compute_users_clubs_score(cls, user1: User, user2: User) -> int:\n \"\"\"Compute the clubs score of the relation between the given users.\n\n The club score is obtained by counting the number of days\n during which the memberships (see :class:`club.models.Membership`)\n of both users overlapped.\n\n For example, if user1 was a member of Unitec from 01/01/2020 to 31/12/2021\n (two years) and user2 was a member of the same club from 01/01/2021 to\n 31/12/2022 (also two years, but with an offset of one year), then their\n club score is 365.\n\n Returns:\n the number of days during which both users were in the same club\n \"\"\"\n common_clubs = Club.objects.filter(members__in=user1.memberships.all()).filter(\n members__in=user2.memberships.all()\n )\n user1_memberships = user1.memberships.filter(club__in=common_clubs)\n user2_memberships = user2.memberships.filter(club__in=common_clubs)\n\n score = 0\n for user1_membership in user1_memberships:\n if user1_membership.end_date is None:\n # user1_membership.save() is not called in this function, hence this is safe\n user1_membership.end_date = localdate()\n query = Q( # start2 <= start1 <= end2\n start_date__lte=user1_membership.start_date,\n end_date__gte=user1_membership.start_date,\n )\n query |= Q( # start2 <= start1 <= now\n start_date__lte=user1_membership.start_date, end_date=None\n )\n query |= Q( # start1 <= start2 <= end2\n start_date__gte=user1_membership.start_date,\n start_date__lte=user1_membership.end_date,\n )\n for user2_membership in user2_memberships.filter(\n query, club=user1_membership.club\n ):\n if user2_membership.end_date is None:\n user2_membership.end_date = localdate()\n latest_start = max(\n user1_membership.start_date, user2_membership.start_date\n )\n earliest_end = min(user1_membership.end_date, user2_membership.end_date)\n cls.logger.debug(\n \"\\t\\t- '%s' was with '%s' in %s starting on %s until %s (%s days)\"\n % (\n user1,\n user2,\n user2_membership.club,\n latest_start,\n earliest_end,\n (earliest_end - latest_start).days,\n )\n )\n score += cls.CLUBS_POINTS * (earliest_end - latest_start).days\n return score\n
"},{"location":"reference/galaxy/models/#galaxy.models.Galaxy.scale_distance","title":"scale_distance(value)
classmethod
","text":"Given a numeric value, return a scaled value which can be used in the Galaxy's graphical interface to set the distance between two stars.
Returns:
Type Descriptionint
the scaled value usable in the Galaxy's 3d graph
Source code ingalaxy/models.py
@classmethod\ndef scale_distance(cls, value: int | float) -> int:\n \"\"\"Given a numeric value, return a scaled value which can\n be used in the Galaxy's graphical interface to set the distance\n between two stars.\n\n Returns:\n the scaled value usable in the Galaxy's 3d graph\n \"\"\"\n # TODO: this will need adjustements with the real, typical data on Taiste\n if value == 0:\n return 4000 # Following calculus would give us +\u221e, we cap it to 4000\n\n cls.logger.debug(f\"\\t\\t> Score: {value}\")\n # Invert score to draw close users together\n value = 1 / value # Cannot be 0\n value += 2 # We use log2 just below and need to stay above 1\n value = ( # Let's get something in the range ]0; log2(3)-1\u22480.58[ that we can multiply later\n math.log2(value) - 1\n )\n value *= ( # Scale that value with a magic number to accommodate to typical data\n # Really close galaxy citizen after 5 years typically have a score of about XXX\n # Citizen that were in the same year without being really friends typically have a score of about XXX\n # Citizen that have met once or twice only have a couple of pictures together typically score about XXX\n cls.GALAXY_SCALE_FACTOR\n )\n cls.logger.debug(f\"\\t\\t> Scaled distance: {value}\")\n return int(value)\n
"},{"location":"reference/galaxy/models/#galaxy.models.Galaxy.rule","title":"rule(picture_count_threshold=10)
","text":"Main function of the Galaxy.
Iterate over all the rulable users to promote them to citizens. A citizen is a user who has a corresponding star in the Galaxy. Also build up the lanes, which are the links between the different citizen.
Users who can be ruled are defined with the picture_count_threshold
: all users who are identified in a strictly lower number of pictures won't be promoted to citizens. This does very effectively limit the quantity of computing to do and only includes users who have had a minimum of activity.
This method still remains very expensive, so think thoroughly before you call it, especially in production.
:param picture_count_threshold: the minimum number of picture to have to be included in the galaxy
Source code ingalaxy/models.py
def rule(self, picture_count_threshold=10) -> None:\n \"\"\"Main function of the Galaxy.\n\n Iterate over all the rulable users to promote them to citizens.\n A citizen is a user who has a corresponding star in the Galaxy.\n Also build up the lanes, which are the links between the different citizen.\n\n Users who can be ruled are defined with the `picture_count_threshold`:\n all users who are identified in a strictly lower number of pictures\n won't be promoted to citizens.\n This does very effectively limit the quantity of computing to do\n and only includes users who have had a minimum of activity.\n\n This method still remains very expensive, so think thoroughly before\n you call it, especially in production.\n\n :param picture_count_threshold: the minimum number of picture to have to be\n included in the galaxy\n \"\"\"\n total_time = time.time()\n self.logger.info(\"Listing rulable citizen.\")\n rulable_users = (\n User.objects.filter(subscriptions__isnull=False)\n .annotate(pictures_count=Count(\"pictures\"))\n .filter(pictures_count__gt=picture_count_threshold)\n .distinct()\n )\n\n # force fetch of the whole query to make sure there won't\n # be any more db hits\n # this is memory expensive but prevents a lot of db hits, therefore\n # is far more time efficient\n\n rulable_users = list(rulable_users)\n rulable_users_count = len(rulable_users)\n user1_count = 0\n self.logger.info(\n f\"{rulable_users_count} citizen have been listed. Starting to rule.\"\n )\n\n stars = []\n self.logger.info(\"Creating stars for all citizen\")\n for user in rulable_users:\n star = GalaxyStar(\n owner=user, galaxy=self, mass=self.compute_user_score(user)\n )\n stars.append(star)\n GalaxyStar.objects.bulk_create(stars)\n\n stars = {}\n for star in GalaxyStar.objects.filter(galaxy=self):\n stars[star.owner.id] = star\n\n self.logger.info(\"Creating lanes between stars\")\n # Display current speed every $speed_count_frequency users\n speed_count_frequency = max(rulable_users_count // 10, 1) # ten time at most\n global_avg_speed_accumulator = 0\n global_avg_speed_count = 0\n t_global_start = time.time()\n while len(rulable_users) > 0:\n user1 = rulable_users.pop()\n user1_count += 1\n rulable_users_count2 = len(rulable_users)\n\n star1 = stars[user1.id]\n\n user_avg_speed = 0\n user_avg_speed_count = 0\n\n tstart = time.time()\n lanes = []\n for user2_count, user2 in enumerate(rulable_users, start=1):\n self.logger.debug(\"\")\n self.logger.debug(\n f\"\\t> Examining '{user1}' ({user1_count}/{rulable_users_count}) with '{user2}' ({user2_count}/{rulable_users_count2})\"\n )\n\n star2 = stars[user2.id]\n\n score = Galaxy.compute_users_score(user1, user2)\n distance = self.scale_distance(sum(score))\n if distance < 30: # TODO: this needs tuning with real-world data\n lanes.append(\n GalaxyLane(\n star1=star1,\n star2=star2,\n distance=distance,\n family=score.family,\n pictures=score.pictures,\n clubs=score.clubs,\n )\n )\n\n if user2_count % speed_count_frequency == 0:\n tend = time.time()\n delta = tend - tstart\n speed = float(speed_count_frequency) / delta\n user_avg_speed += speed\n user_avg_speed_count += 1\n self.logger.debug(\n f\"\\tSpeed: {speed:.2f} users per second (time for last {speed_count_frequency} citizens: {delta:.2f} second)\"\n )\n tstart = time.time()\n\n GalaxyLane.objects.bulk_create(lanes)\n\n self.logger.info(\"\")\n\n t_global_end = time.time()\n global_delta = t_global_end - t_global_start\n speed = 1.0 / global_delta\n global_avg_speed_accumulator += speed\n global_avg_speed_count += 1\n global_avg_speed = global_avg_speed_accumulator / global_avg_speed_count\n\n self.logger.info(f\" Ruling of {self} \".center(60, \"#\"))\n self.logger.info(\n f\"Progression: {user1_count}/{rulable_users_count} citizen -- {rulable_users_count - user1_count} remaining\"\n )\n self.logger.info(f\"Speed: {60.0*global_avg_speed:.2f} citizen per minute\")\n\n # We can divide the computed ETA by 2 because each loop, there is one citizen less to check, and maths tell\n # us that this averages to a division by two\n eta = rulable_users_count2 / global_avg_speed / 2\n eta_hours = int(eta // 3600)\n eta_minutes = int(eta // 60 % 60)\n self.logger.info(\n f\"ETA: {eta_hours} hours {eta_minutes} minutes ({eta / 3600 / 24:.2f} days)\"\n )\n self.logger.info(\"#\" * 60)\n t_global_start = time.time()\n\n # Here, we get the IDs of the old galaxies that we'll need to delete. In normal operation, only one galaxy\n # should be returned, and we can't delete it yet, as it's the one still displayed by the Sith.\n old_galaxies_pks = list(\n Galaxy.objects.filter(state__isnull=False).values_list(\"pk\", flat=True)\n )\n self.logger.info(\n f\"These old galaxies will be deleted once the new one is ready: {old_galaxies_pks}\"\n )\n\n # Making the state sets this new galaxy as being ready. From now on, the Sith will show us to the world.\n self.make_state()\n\n # Avoid accident if there is nothing to delete\n if len(old_galaxies_pks) > 0:\n # Former galaxies can now be deleted.\n Galaxy.objects.filter(pk__in=old_galaxies_pks).delete()\n\n total_time = time.time() - total_time\n total_time_hours = int(total_time // 3600)\n total_time_minutes = int(total_time // 60 % 60)\n total_time_seconds = int(total_time % 60)\n self.logger.info(\n f\"{self} ruled in {total_time:.2f} seconds ({total_time_hours} hours, {total_time_minutes} minutes, {total_time_seconds} seconds)\"\n )\n
"},{"location":"reference/galaxy/models/#galaxy.models.Galaxy.make_state","title":"make_state()
","text":"Compute JSON structure to send to 3d-force-graph: https://github.com/vasturiano/3d-force-graph/.
Source code ingalaxy/models.py
def make_state(self) -> None:\n \"\"\"Compute JSON structure to send to 3d-force-graph: https://github.com/vasturiano/3d-force-graph/.\"\"\"\n self.logger.info(\n \"Caching current Galaxy state for a quicker display of the Empire's power.\"\n )\n\n without_nickname = Concat(\n F(\"owner__first_name\"), Value(\" \"), F(\"owner__last_name\")\n )\n with_nickname = Concat(\n F(\"owner__first_name\"),\n Value(\" \"),\n F(\"owner__last_name\"),\n Value(\" (\"),\n F(\"owner__nick_name\"),\n Value(\")\"),\n )\n stars = (\n GalaxyStar.objects.filter(galaxy=self)\n .order_by(\n \"owner\"\n ) # This helps determinism for the tests and doesn't cost much\n .annotate(\n owner_name=Case(\n When(owner__nick_name=None, then=without_nickname),\n default=with_nickname,\n )\n )\n )\n lanes = (\n GalaxyLane.objects.filter(star1__galaxy=self)\n .order_by(\n \"star1\"\n ) # This helps determinism for the tests and doesn't cost much\n .annotate(\n star1_owner=F(\"star1__owner__id\"),\n star2_owner=F(\"star2__owner__id\"),\n )\n )\n json = GalaxyDict(\n nodes=[\n StarDict(\n id=star.owner_id,\n name=star.owner_name,\n mass=star.mass,\n )\n for star in stars\n ],\n links=[],\n )\n for path in lanes:\n json[\"links\"].append(\n {\n \"source\": path.star1_owner,\n \"target\": path.star2_owner,\n \"value\": path.distance,\n }\n )\n self.state = json\n self.save()\n self.logger.info(f\"{self} is now ready!\")\n
"},{"location":"reference/galaxy/views/","title":"Views","text":""},{"location":"reference/galaxy/views/#galaxy.views.User","title":"User
","text":" Bases: AbstractBaseUser
Defines the base user class, useable in every app.
This is almost the same as the auth module AbstractUser since it inherits from it, but some fields are required, and the username is generated automatically with the name of the user (see generate_username()).
Added field: nick_name, date_of_birth Required fields: email, first_name, last_name, date_of_birth
"},{"location":"reference/galaxy/views/#galaxy.views.User.cached_groups","title":"cached_groups: list[Group]
property
","text":"Get the list of groups this user is in.
The result is cached for the default duration (should be 5 minutes)
Returns: A list of all the groups this user is in.
"},{"location":"reference/galaxy/views/#galaxy.views.User.is_in_group","title":"is_in_group(*, pk=None, name=None)
","text":"Check if this user is in the given group. Either a group id or a group name must be provided. If both are passed, only the id will be considered.
The group will be fetched using the given parameter. If no group is found, return False. If a group is found, check if this user is in the latter.
Returns:
Type Descriptionbool
True if the user is the group, else False
Source code incore/models.py
def is_in_group(self, *, pk: int | None = None, name: str | None = None) -> bool:\n \"\"\"Check if this user is in the given group.\n Either a group id or a group name must be provided.\n If both are passed, only the id will be considered.\n\n The group will be fetched using the given parameter.\n If no group is found, return False.\n If a group is found, check if this user is in the latter.\n\n Returns:\n True if the user is the group, else False\n \"\"\"\n if pk is not None:\n group: Optional[Group] = get_group(pk=pk)\n elif name is not None:\n group: Optional[Group] = get_group(name=name)\n else:\n raise ValueError(\"You must either provide the id or the name of the group\")\n if group is None:\n return False\n if group.id == settings.SITH_GROUP_PUBLIC_ID:\n return True\n if group.id == settings.SITH_GROUP_SUBSCRIBERS_ID:\n return self.is_subscribed\n if group.id == settings.SITH_GROUP_OLD_SUBSCRIBERS_ID:\n return self.was_subscribed\n if group.id == settings.SITH_GROUP_ROOT_ID:\n return self.is_root\n if group.is_meta:\n # check if this group is associated with a club\n group.__class__ = MetaGroup\n club = group.associated_club\n if club is None:\n return False\n membership = club.get_membership_for(self)\n if membership is None:\n return False\n if group.name.endswith(settings.SITH_MEMBER_SUFFIX):\n return True\n return membership.role > settings.SITH_MAXIMUM_FREE_ROLE\n return group in self.cached_groups\n
"},{"location":"reference/galaxy/views/#galaxy.views.User.age","title":"age()
","text":"Return the age this user has the day the method is called. If the user has not filled his age, return 0.
Source code incore/models.py
@cached_property\ndef age(self) -> int:\n \"\"\"Return the age this user has the day the method is called.\n If the user has not filled his age, return 0.\n \"\"\"\n if self.date_of_birth is None:\n return 0\n today = timezone.now()\n age = today.year - self.date_of_birth.year\n # remove a year if this year's birthday is yet to come\n age -= (today.month, today.day) < (\n self.date_of_birth.month,\n self.date_of_birth.day,\n )\n return age\n
"},{"location":"reference/galaxy/views/#galaxy.views.User.get_full_name","title":"get_full_name()
","text":"Returns the first_name plus the last_name, with a space in between.
Source code incore/models.py
def get_full_name(self):\n \"\"\"Returns the first_name plus the last_name, with a space in between.\"\"\"\n full_name = \"%s %s\" % (self.first_name, self.last_name)\n return full_name.strip()\n
"},{"location":"reference/galaxy/views/#galaxy.views.User.get_short_name","title":"get_short_name()
","text":"Returns the short name for the user.
Source code incore/models.py
def get_short_name(self):\n \"\"\"Returns the short name for the user.\"\"\"\n if self.nick_name:\n return self.nick_name\n return self.first_name + \" \" + self.last_name\n
"},{"location":"reference/galaxy/views/#galaxy.views.User.get_display_name","title":"get_display_name()
","text":"Returns the display name of the user.
A nickname if possible, otherwise, the full name.
Source code incore/models.py
def get_display_name(self) -> str:\n \"\"\"Returns the display name of the user.\n\n A nickname if possible, otherwise, the full name.\n \"\"\"\n if self.nick_name:\n return \"%s (%s)\" % (self.get_full_name(), self.nick_name)\n return self.get_full_name()\n
"},{"location":"reference/galaxy/views/#galaxy.views.User.get_age","title":"get_age()
","text":"Returns the age.
Source code incore/models.py
def get_age(self):\n \"\"\"Returns the age.\"\"\"\n today = timezone.now()\n born = self.date_of_birth\n return (\n today.year - born.year - ((today.month, today.day) < (born.month, born.day))\n )\n
"},{"location":"reference/galaxy/views/#galaxy.views.User.get_family","title":"get_family(godfathers_depth=4, godchildren_depth=4)
","text":"Get the family of the user, with the given depth.
Parameters:
Name Type Description Defaultgodfathers_depth
NonNegativeInt
The number of generations of godfathers to fetch
4
godchildren_depth
NonNegativeInt
The number of generations of godchildren to fetch
4
Returns:
Type Descriptionset[through]
A list of family relationships in this user's family
Source code incore/models.py
def get_family(\n self,\n godfathers_depth: NonNegativeInt = 4,\n godchildren_depth: NonNegativeInt = 4,\n) -> set[User.godfathers.through]:\n \"\"\"Get the family of the user, with the given depth.\n\n Args:\n godfathers_depth: The number of generations of godfathers to fetch\n godchildren_depth: The number of generations of godchildren to fetch\n\n Returns:\n A list of family relationships in this user's family\n \"\"\"\n res = []\n for depth, key, reverse_key in [\n (godfathers_depth, \"from_user_id\", \"to_user_id\"),\n (godchildren_depth, \"to_user_id\", \"from_user_id\"),\n ]:\n if depth == 0:\n continue\n links = list(User.godfathers.through.objects.filter(**{key: self.id}))\n res.extend(links)\n for _ in range(1, depth): # noqa: F402 we don't care about gettext here\n ids = [getattr(c, reverse_key) for c in links]\n links = list(\n User.godfathers.through.objects.filter(\n **{f\"{key}__in\": ids}\n ).exclude(id__in=[r.id for r in res])\n )\n if not links:\n break\n res.extend(links)\n return set(res)\n
"},{"location":"reference/galaxy/views/#galaxy.views.User.email_user","title":"email_user(subject, message, from_email=None, **kwargs)
","text":"Sends an email to this User.
Source code incore/models.py
def email_user(self, subject, message, from_email=None, **kwargs):\n \"\"\"Sends an email to this User.\"\"\"\n if from_email is None:\n from_email = settings.DEFAULT_FROM_EMAIL\n send_mail(subject, message, from_email, [self.email], **kwargs)\n
"},{"location":"reference/galaxy/views/#galaxy.views.User.generate_username","title":"generate_username()
","text":"Generates a unique username based on the first and last names.
For example: Guy Carlier gives gcarlier, and gcarlier1 if the first one exists.
Returns:
Type Descriptionstr
The generated username.
Source code incore/models.py
def generate_username(self) -> str:\n \"\"\"Generates a unique username based on the first and last names.\n\n For example: Guy Carlier gives gcarlier, and gcarlier1 if the first one exists.\n\n Returns:\n The generated username.\n \"\"\"\n\n def remove_accents(data):\n return \"\".join(\n x\n for x in unicodedata.normalize(\"NFKD\", data)\n if unicodedata.category(x)[0] == \"L\"\n ).lower()\n\n user_name = (\n remove_accents(self.first_name[0] + self.last_name)\n .encode(\"ascii\", \"ignore\")\n .decode(\"utf-8\")\n )\n un_set = [u.username for u in User.objects.all()]\n if user_name in un_set:\n i = 1\n while user_name + str(i) in un_set:\n i += 1\n user_name += str(i)\n self.username = user_name\n return user_name\n
"},{"location":"reference/galaxy/views/#galaxy.views.User.is_owner","title":"is_owner(obj)
","text":"Determine if the object is owned by the user.
Source code incore/models.py
def is_owner(self, obj):\n \"\"\"Determine if the object is owned by the user.\"\"\"\n if hasattr(obj, \"is_owned_by\") and obj.is_owned_by(self):\n return True\n if hasattr(obj, \"owner_group\") and self.is_in_group(pk=obj.owner_group.id):\n return True\n return self.is_root\n
"},{"location":"reference/galaxy/views/#galaxy.views.User.can_edit","title":"can_edit(obj)
","text":"Determine if the object can be edited by the user.
Source code incore/models.py
def can_edit(self, obj):\n \"\"\"Determine if the object can be edited by the user.\"\"\"\n if hasattr(obj, \"can_be_edited_by\") and obj.can_be_edited_by(self):\n return True\n if hasattr(obj, \"edit_groups\"):\n for pk in obj.edit_groups.values_list(\"pk\", flat=True):\n if self.is_in_group(pk=pk):\n return True\n if isinstance(obj, User) and obj == self:\n return True\n return self.is_owner(obj)\n
"},{"location":"reference/galaxy/views/#galaxy.views.User.can_view","title":"can_view(obj)
","text":"Determine if the object can be viewed by the user.
Source code incore/models.py
def can_view(self, obj):\n \"\"\"Determine if the object can be viewed by the user.\"\"\"\n if hasattr(obj, \"can_be_viewed_by\") and obj.can_be_viewed_by(self):\n return True\n if hasattr(obj, \"view_groups\"):\n for pk in obj.view_groups.values_list(\"pk\", flat=True):\n if self.is_in_group(pk=pk):\n return True\n return self.can_edit(obj)\n
"},{"location":"reference/galaxy/views/#galaxy.views.User.clubs_with_rights","title":"clubs_with_rights()
","text":"The list of clubs where the user has rights
Source code incore/models.py
@cached_property\ndef clubs_with_rights(self) -> list[Club]:\n \"\"\"The list of clubs where the user has rights\"\"\"\n memberships = self.memberships.ongoing().board().select_related(\"club\")\n return [m.club for m in memberships]\n
"},{"location":"reference/galaxy/views/#galaxy.views.CanViewMixin","title":"CanViewMixin
","text":" Bases: GenericContentPermissionMixinBuilder
Ensure the user has permission to view this view's object.
Raises:
Type DescriptionPermissionDenied
if the user cannot edit this view's object.
"},{"location":"reference/galaxy/views/#galaxy.views.FormerSubscriberMixin","title":"FormerSubscriberMixin
","text":" Bases: AccessMixin
Check if the user was at least an old subscriber.
Raises:
Type DescriptionPermissionDenied
if the user never subscribed.
"},{"location":"reference/galaxy/views/#galaxy.views.UserTabsMixin","title":"UserTabsMixin
","text":" Bases: TabedViewMixin
Galaxy
","text":" Bases: Model
The Galaxy, a graph linking the active users between each others.
The distance between two users is given by a relation score which takes into account a few parameter like the number of pictures they are both tagged on, the time during which they were in the same clubs and whether they are in the same family.
The citizens of the Galaxy are represented by :class:GalaxyStar
and their relations by :class:GalaxyLane
.
Several galaxies can coexist. In this case, only the most recent active one shall usually be taken into account. This is useful to keep the current galaxy while generating a new one and swapping them only at the very end.
Please take into account that generating the galaxy is a very expensive operation. For this reason, try not to call the :meth:rule
method more than once a day in production.
To quickly access to the state of a galaxy, use the :attr:state
attribute.
compute_user_score(user)
classmethod
","text":"Compute an individual score for each citizen.
It will later be used by the graph algorithm to push higher scores towards the center of the galaxy.
Idea: This could be added to the computation:
galaxy/models.py
@classmethod\ndef compute_user_score(cls, user: User) -> int:\n \"\"\"Compute an individual score for each citizen.\n\n It will later be used by the graph algorithm to push\n higher scores towards the center of the galaxy.\n\n Idea: This could be added to the computation:\n\n - Forum posts\n - Picture count\n - Counter consumption\n - Barman time\n - ...\n \"\"\"\n user_score = 1\n user_score += cls.query_user_score(user)\n\n # TODO:\n # Scale that value with some magic number to accommodate to typical data\n # Really active galaxy citizen after 5 years typically have a score of about XXX\n # Citizen that were seen regularly without taking much part in organizations typically have a score of about XXX\n # Citizen that only went to a few events typically score about XXX\n user_score = int(math.log2(user_score))\n\n return user_score\n
"},{"location":"reference/galaxy/views/#galaxy.views.Galaxy.query_user_score","title":"query_user_score(user)
classmethod
","text":"Get the individual score of the given user in the galaxy.
Source code ingalaxy/models.py
@classmethod\ndef query_user_score(cls, user: User) -> int:\n \"\"\"Get the individual score of the given user in the galaxy.\"\"\"\n score_query = (\n User.objects.filter(id=user.id)\n .annotate(\n godchildren_count=Count(\"godchildren\", distinct=True)\n * cls.FAMILY_LINK_POINTS,\n godfathers_count=Count(\"godfathers\", distinct=True)\n * cls.FAMILY_LINK_POINTS,\n pictures_score=Count(\"pictures\", distinct=True) * cls.PICTURE_POINTS,\n clubs_score=Count(\"memberships\", distinct=True) * cls.CLUBS_POINTS,\n )\n .aggregate(\n score=models.Sum(\n F(\"godchildren_count\")\n + F(\"godfathers_count\")\n + F(\"pictures_score\")\n + F(\"clubs_score\")\n )\n )\n )\n return score_query.get(\"score\")\n
"},{"location":"reference/galaxy/views/#galaxy.views.Galaxy.compute_users_score","title":"compute_users_score(user1, user2)
classmethod
","text":"Compute the relationship scores of the two given users.
The computation is done with the following fields :
galaxy/models.py
@classmethod\ndef compute_users_score(cls, user1: User, user2: User) -> RelationScore:\n \"\"\"Compute the relationship scores of the two given users.\n\n The computation is done with the following fields :\n\n - family: if they have some godfather/godchild relation\n - pictures: in how many pictures are both tagged\n - clubs: during how many days they were members of the same clubs\n \"\"\"\n family = cls.compute_users_family_score(user1, user2)\n pictures = cls.compute_users_pictures_score(user1, user2)\n clubs = cls.compute_users_clubs_score(user1, user2)\n return RelationScore(family=family, pictures=pictures, clubs=clubs)\n
"},{"location":"reference/galaxy/views/#galaxy.views.Galaxy.compute_users_family_score","title":"compute_users_family_score(user1, user2)
classmethod
","text":"Compute the family score of the relation between the given users.
This takes into account mutual godfathers.
Returns:
Type Descriptionint
366 if user1 is the godfather of user2 (or vice versa) else 0
Source code ingalaxy/models.py
@classmethod\ndef compute_users_family_score(cls, user1: User, user2: User) -> int:\n \"\"\"Compute the family score of the relation between the given users.\n\n This takes into account mutual godfathers.\n\n Returns:\n 366 if user1 is the godfather of user2 (or vice versa) else 0\n \"\"\"\n link_count = User.objects.filter(\n Q(id=user1.id, godfathers=user2) | Q(id=user2.id, godfathers=user1)\n ).count()\n if link_count > 0:\n cls.logger.debug(\n f\"\\t\\t- '{user1}' and '{user2}' have {link_count} direct family link\"\n )\n return link_count * cls.FAMILY_LINK_POINTS\n
"},{"location":"reference/galaxy/views/#galaxy.views.Galaxy.compute_users_pictures_score","title":"compute_users_pictures_score(user1, user2)
classmethod
","text":"Compute the pictures score of the relation between the given users.
The pictures score is obtained by counting the number of :class:Picture
in which they have been both identified. This score is then multiplied by 2.
Returns:
Type Descriptionint
The number of pictures both users have in common, times 2
Source code ingalaxy/models.py
@classmethod\ndef compute_users_pictures_score(cls, user1: User, user2: User) -> int:\n \"\"\"Compute the pictures score of the relation between the given users.\n\n The pictures score is obtained by counting the number\n of :class:`Picture` in which they have been both identified.\n This score is then multiplied by 2.\n\n Returns:\n The number of pictures both users have in common, times 2\n \"\"\"\n picture_count = (\n Picture.objects.filter(people__user__in=(user1,))\n .filter(people__user__in=(user2,))\n .count()\n )\n if picture_count:\n cls.logger.debug(\n f\"\\t\\t- '{user1}' was pictured with '{user2}' {picture_count} times\"\n )\n return picture_count * cls.PICTURE_POINTS\n
"},{"location":"reference/galaxy/views/#galaxy.views.Galaxy.compute_users_clubs_score","title":"compute_users_clubs_score(user1, user2)
classmethod
","text":"Compute the clubs score of the relation between the given users.
The club score is obtained by counting the number of days during which the memberships (see :class:club.models.Membership
) of both users overlapped.
For example, if user1 was a member of Unitec from 01/01/2020 to 31/12/2021 (two years) and user2 was a member of the same club from 01/01/2021 to 31/12/2022 (also two years, but with an offset of one year), then their club score is 365.
Returns:
Type Descriptionint
the number of days during which both users were in the same club
Source code ingalaxy/models.py
@classmethod\ndef compute_users_clubs_score(cls, user1: User, user2: User) -> int:\n \"\"\"Compute the clubs score of the relation between the given users.\n\n The club score is obtained by counting the number of days\n during which the memberships (see :class:`club.models.Membership`)\n of both users overlapped.\n\n For example, if user1 was a member of Unitec from 01/01/2020 to 31/12/2021\n (two years) and user2 was a member of the same club from 01/01/2021 to\n 31/12/2022 (also two years, but with an offset of one year), then their\n club score is 365.\n\n Returns:\n the number of days during which both users were in the same club\n \"\"\"\n common_clubs = Club.objects.filter(members__in=user1.memberships.all()).filter(\n members__in=user2.memberships.all()\n )\n user1_memberships = user1.memberships.filter(club__in=common_clubs)\n user2_memberships = user2.memberships.filter(club__in=common_clubs)\n\n score = 0\n for user1_membership in user1_memberships:\n if user1_membership.end_date is None:\n # user1_membership.save() is not called in this function, hence this is safe\n user1_membership.end_date = localdate()\n query = Q( # start2 <= start1 <= end2\n start_date__lte=user1_membership.start_date,\n end_date__gte=user1_membership.start_date,\n )\n query |= Q( # start2 <= start1 <= now\n start_date__lte=user1_membership.start_date, end_date=None\n )\n query |= Q( # start1 <= start2 <= end2\n start_date__gte=user1_membership.start_date,\n start_date__lte=user1_membership.end_date,\n )\n for user2_membership in user2_memberships.filter(\n query, club=user1_membership.club\n ):\n if user2_membership.end_date is None:\n user2_membership.end_date = localdate()\n latest_start = max(\n user1_membership.start_date, user2_membership.start_date\n )\n earliest_end = min(user1_membership.end_date, user2_membership.end_date)\n cls.logger.debug(\n \"\\t\\t- '%s' was with '%s' in %s starting on %s until %s (%s days)\"\n % (\n user1,\n user2,\n user2_membership.club,\n latest_start,\n earliest_end,\n (earliest_end - latest_start).days,\n )\n )\n score += cls.CLUBS_POINTS * (earliest_end - latest_start).days\n return score\n
"},{"location":"reference/galaxy/views/#galaxy.views.Galaxy.scale_distance","title":"scale_distance(value)
classmethod
","text":"Given a numeric value, return a scaled value which can be used in the Galaxy's graphical interface to set the distance between two stars.
Returns:
Type Descriptionint
the scaled value usable in the Galaxy's 3d graph
Source code ingalaxy/models.py
@classmethod\ndef scale_distance(cls, value: int | float) -> int:\n \"\"\"Given a numeric value, return a scaled value which can\n be used in the Galaxy's graphical interface to set the distance\n between two stars.\n\n Returns:\n the scaled value usable in the Galaxy's 3d graph\n \"\"\"\n # TODO: this will need adjustements with the real, typical data on Taiste\n if value == 0:\n return 4000 # Following calculus would give us +\u221e, we cap it to 4000\n\n cls.logger.debug(f\"\\t\\t> Score: {value}\")\n # Invert score to draw close users together\n value = 1 / value # Cannot be 0\n value += 2 # We use log2 just below and need to stay above 1\n value = ( # Let's get something in the range ]0; log2(3)-1\u22480.58[ that we can multiply later\n math.log2(value) - 1\n )\n value *= ( # Scale that value with a magic number to accommodate to typical data\n # Really close galaxy citizen after 5 years typically have a score of about XXX\n # Citizen that were in the same year without being really friends typically have a score of about XXX\n # Citizen that have met once or twice only have a couple of pictures together typically score about XXX\n cls.GALAXY_SCALE_FACTOR\n )\n cls.logger.debug(f\"\\t\\t> Scaled distance: {value}\")\n return int(value)\n
"},{"location":"reference/galaxy/views/#galaxy.views.Galaxy.rule","title":"rule(picture_count_threshold=10)
","text":"Main function of the Galaxy.
Iterate over all the rulable users to promote them to citizens. A citizen is a user who has a corresponding star in the Galaxy. Also build up the lanes, which are the links between the different citizen.
Users who can be ruled are defined with the picture_count_threshold
: all users who are identified in a strictly lower number of pictures won't be promoted to citizens. This does very effectively limit the quantity of computing to do and only includes users who have had a minimum of activity.
This method still remains very expensive, so think thoroughly before you call it, especially in production.
:param picture_count_threshold: the minimum number of picture to have to be included in the galaxy
Source code ingalaxy/models.py
def rule(self, picture_count_threshold=10) -> None:\n \"\"\"Main function of the Galaxy.\n\n Iterate over all the rulable users to promote them to citizens.\n A citizen is a user who has a corresponding star in the Galaxy.\n Also build up the lanes, which are the links between the different citizen.\n\n Users who can be ruled are defined with the `picture_count_threshold`:\n all users who are identified in a strictly lower number of pictures\n won't be promoted to citizens.\n This does very effectively limit the quantity of computing to do\n and only includes users who have had a minimum of activity.\n\n This method still remains very expensive, so think thoroughly before\n you call it, especially in production.\n\n :param picture_count_threshold: the minimum number of picture to have to be\n included in the galaxy\n \"\"\"\n total_time = time.time()\n self.logger.info(\"Listing rulable citizen.\")\n rulable_users = (\n User.objects.filter(subscriptions__isnull=False)\n .annotate(pictures_count=Count(\"pictures\"))\n .filter(pictures_count__gt=picture_count_threshold)\n .distinct()\n )\n\n # force fetch of the whole query to make sure there won't\n # be any more db hits\n # this is memory expensive but prevents a lot of db hits, therefore\n # is far more time efficient\n\n rulable_users = list(rulable_users)\n rulable_users_count = len(rulable_users)\n user1_count = 0\n self.logger.info(\n f\"{rulable_users_count} citizen have been listed. Starting to rule.\"\n )\n\n stars = []\n self.logger.info(\"Creating stars for all citizen\")\n for user in rulable_users:\n star = GalaxyStar(\n owner=user, galaxy=self, mass=self.compute_user_score(user)\n )\n stars.append(star)\n GalaxyStar.objects.bulk_create(stars)\n\n stars = {}\n for star in GalaxyStar.objects.filter(galaxy=self):\n stars[star.owner.id] = star\n\n self.logger.info(\"Creating lanes between stars\")\n # Display current speed every $speed_count_frequency users\n speed_count_frequency = max(rulable_users_count // 10, 1) # ten time at most\n global_avg_speed_accumulator = 0\n global_avg_speed_count = 0\n t_global_start = time.time()\n while len(rulable_users) > 0:\n user1 = rulable_users.pop()\n user1_count += 1\n rulable_users_count2 = len(rulable_users)\n\n star1 = stars[user1.id]\n\n user_avg_speed = 0\n user_avg_speed_count = 0\n\n tstart = time.time()\n lanes = []\n for user2_count, user2 in enumerate(rulable_users, start=1):\n self.logger.debug(\"\")\n self.logger.debug(\n f\"\\t> Examining '{user1}' ({user1_count}/{rulable_users_count}) with '{user2}' ({user2_count}/{rulable_users_count2})\"\n )\n\n star2 = stars[user2.id]\n\n score = Galaxy.compute_users_score(user1, user2)\n distance = self.scale_distance(sum(score))\n if distance < 30: # TODO: this needs tuning with real-world data\n lanes.append(\n GalaxyLane(\n star1=star1,\n star2=star2,\n distance=distance,\n family=score.family,\n pictures=score.pictures,\n clubs=score.clubs,\n )\n )\n\n if user2_count % speed_count_frequency == 0:\n tend = time.time()\n delta = tend - tstart\n speed = float(speed_count_frequency) / delta\n user_avg_speed += speed\n user_avg_speed_count += 1\n self.logger.debug(\n f\"\\tSpeed: {speed:.2f} users per second (time for last {speed_count_frequency} citizens: {delta:.2f} second)\"\n )\n tstart = time.time()\n\n GalaxyLane.objects.bulk_create(lanes)\n\n self.logger.info(\"\")\n\n t_global_end = time.time()\n global_delta = t_global_end - t_global_start\n speed = 1.0 / global_delta\n global_avg_speed_accumulator += speed\n global_avg_speed_count += 1\n global_avg_speed = global_avg_speed_accumulator / global_avg_speed_count\n\n self.logger.info(f\" Ruling of {self} \".center(60, \"#\"))\n self.logger.info(\n f\"Progression: {user1_count}/{rulable_users_count} citizen -- {rulable_users_count - user1_count} remaining\"\n )\n self.logger.info(f\"Speed: {60.0*global_avg_speed:.2f} citizen per minute\")\n\n # We can divide the computed ETA by 2 because each loop, there is one citizen less to check, and maths tell\n # us that this averages to a division by two\n eta = rulable_users_count2 / global_avg_speed / 2\n eta_hours = int(eta // 3600)\n eta_minutes = int(eta // 60 % 60)\n self.logger.info(\n f\"ETA: {eta_hours} hours {eta_minutes} minutes ({eta / 3600 / 24:.2f} days)\"\n )\n self.logger.info(\"#\" * 60)\n t_global_start = time.time()\n\n # Here, we get the IDs of the old galaxies that we'll need to delete. In normal operation, only one galaxy\n # should be returned, and we can't delete it yet, as it's the one still displayed by the Sith.\n old_galaxies_pks = list(\n Galaxy.objects.filter(state__isnull=False).values_list(\"pk\", flat=True)\n )\n self.logger.info(\n f\"These old galaxies will be deleted once the new one is ready: {old_galaxies_pks}\"\n )\n\n # Making the state sets this new galaxy as being ready. From now on, the Sith will show us to the world.\n self.make_state()\n\n # Avoid accident if there is nothing to delete\n if len(old_galaxies_pks) > 0:\n # Former galaxies can now be deleted.\n Galaxy.objects.filter(pk__in=old_galaxies_pks).delete()\n\n total_time = time.time() - total_time\n total_time_hours = int(total_time // 3600)\n total_time_minutes = int(total_time // 60 % 60)\n total_time_seconds = int(total_time % 60)\n self.logger.info(\n f\"{self} ruled in {total_time:.2f} seconds ({total_time_hours} hours, {total_time_minutes} minutes, {total_time_seconds} seconds)\"\n )\n
"},{"location":"reference/galaxy/views/#galaxy.views.Galaxy.make_state","title":"make_state()
","text":"Compute JSON structure to send to 3d-force-graph: https://github.com/vasturiano/3d-force-graph/.
Source code ingalaxy/models.py
def make_state(self) -> None:\n \"\"\"Compute JSON structure to send to 3d-force-graph: https://github.com/vasturiano/3d-force-graph/.\"\"\"\n self.logger.info(\n \"Caching current Galaxy state for a quicker display of the Empire's power.\"\n )\n\n without_nickname = Concat(\n F(\"owner__first_name\"), Value(\" \"), F(\"owner__last_name\")\n )\n with_nickname = Concat(\n F(\"owner__first_name\"),\n Value(\" \"),\n F(\"owner__last_name\"),\n Value(\" (\"),\n F(\"owner__nick_name\"),\n Value(\")\"),\n )\n stars = (\n GalaxyStar.objects.filter(galaxy=self)\n .order_by(\n \"owner\"\n ) # This helps determinism for the tests and doesn't cost much\n .annotate(\n owner_name=Case(\n When(owner__nick_name=None, then=without_nickname),\n default=with_nickname,\n )\n )\n )\n lanes = (\n GalaxyLane.objects.filter(star1__galaxy=self)\n .order_by(\n \"star1\"\n ) # This helps determinism for the tests and doesn't cost much\n .annotate(\n star1_owner=F(\"star1__owner__id\"),\n star2_owner=F(\"star2__owner__id\"),\n )\n )\n json = GalaxyDict(\n nodes=[\n StarDict(\n id=star.owner_id,\n name=star.owner_name,\n mass=star.mass,\n )\n for star in stars\n ],\n links=[],\n )\n for path in lanes:\n json[\"links\"].append(\n {\n \"source\": path.star1_owner,\n \"target\": path.star2_owner,\n \"value\": path.distance,\n }\n )\n self.state = json\n self.save()\n self.logger.info(f\"{self} is now ready!\")\n
"},{"location":"reference/galaxy/views/#galaxy.views.GalaxyLane","title":"GalaxyLane
","text":" Bases: Model
Define a lane (edge -> link between galaxy citizen) in the galaxy map.
Store a reference to both its ends and the distance it covers. Score details between citizen owning the stars is also stored here.
"},{"location":"reference/galaxy/views/#galaxy.views.GalaxyUserView","title":"GalaxyUserView
","text":" Bases: CanViewMixin
, UserTabsMixin
, DetailView
GalaxyDataView
","text":" Bases: FormerSubscriberMixin
, View
Club
","text":" Bases: Model
The Club class, made as a tree to allow nice tidy organization.
"},{"location":"reference/launderette/models/#launderette.models.Club.check_loop","title":"check_loop()
","text":"Raise a validation error when a loop is found within the parent list.
Source code inclub/models.py
def check_loop(self):\n \"\"\"Raise a validation error when a loop is found within the parent list.\"\"\"\n objs = []\n cur = self\n while cur.parent is not None:\n if cur in objs:\n raise ValidationError(_(\"You can not make loops in clubs\"))\n objs.append(cur)\n cur = cur.parent\n
"},{"location":"reference/launderette/models/#launderette.models.Club.is_owned_by","title":"is_owned_by(user)
","text":"Method to see if that object can be super edited by the given user.
Source code inclub/models.py
def is_owned_by(self, user):\n \"\"\"Method to see if that object can be super edited by the given user.\"\"\"\n if user.is_anonymous:\n return False\n return user.is_board_member\n
"},{"location":"reference/launderette/models/#launderette.models.Club.can_be_edited_by","title":"can_be_edited_by(user)
","text":"Method to see if that object can be edited by the given user.
Source code inclub/models.py
def can_be_edited_by(self, user):\n \"\"\"Method to see if that object can be edited by the given user.\"\"\"\n return self.has_rights_in_club(user)\n
"},{"location":"reference/launderette/models/#launderette.models.Club.can_be_viewed_by","title":"can_be_viewed_by(user)
","text":"Method to see if that object can be seen by the given user.
Source code inclub/models.py
def can_be_viewed_by(self, user):\n \"\"\"Method to see if that object can be seen by the given user.\"\"\"\n sub = User.objects.filter(pk=user.pk).first()\n if sub is None:\n return False\n return sub.was_subscribed\n
"},{"location":"reference/launderette/models/#launderette.models.Club.get_membership_for","title":"get_membership_for(user)
","text":"Return the current membership the given user.
NoteThe result is cached.
Source code inclub/models.py
def get_membership_for(self, user: User) -> Membership | None:\n \"\"\"Return the current membership the given user.\n\n Note:\n The result is cached.\n \"\"\"\n if user.is_anonymous:\n return None\n membership = cache.get(f\"membership_{self.id}_{user.id}\")\n if membership == \"not_member\":\n return None\n if membership is None:\n membership = self.members.filter(user=user, end_date=None).first()\n if membership is None:\n cache.set(f\"membership_{self.id}_{user.id}\", \"not_member\")\n else:\n cache.set(f\"membership_{self.id}_{user.id}\", membership)\n return membership\n
"},{"location":"reference/launderette/models/#launderette.models.User","title":"User
","text":" Bases: AbstractBaseUser
Defines the base user class, useable in every app.
This is almost the same as the auth module AbstractUser since it inherits from it, but some fields are required, and the username is generated automatically with the name of the user (see generate_username()).
Added field: nick_name, date_of_birth Required fields: email, first_name, last_name, date_of_birth
"},{"location":"reference/launderette/models/#launderette.models.User.cached_groups","title":"cached_groups: list[Group]
property
","text":"Get the list of groups this user is in.
The result is cached for the default duration (should be 5 minutes)
Returns: A list of all the groups this user is in.
"},{"location":"reference/launderette/models/#launderette.models.User.is_in_group","title":"is_in_group(*, pk=None, name=None)
","text":"Check if this user is in the given group. Either a group id or a group name must be provided. If both are passed, only the id will be considered.
The group will be fetched using the given parameter. If no group is found, return False. If a group is found, check if this user is in the latter.
Returns:
Type Descriptionbool
True if the user is the group, else False
Source code incore/models.py
def is_in_group(self, *, pk: int | None = None, name: str | None = None) -> bool:\n \"\"\"Check if this user is in the given group.\n Either a group id or a group name must be provided.\n If both are passed, only the id will be considered.\n\n The group will be fetched using the given parameter.\n If no group is found, return False.\n If a group is found, check if this user is in the latter.\n\n Returns:\n True if the user is the group, else False\n \"\"\"\n if pk is not None:\n group: Optional[Group] = get_group(pk=pk)\n elif name is not None:\n group: Optional[Group] = get_group(name=name)\n else:\n raise ValueError(\"You must either provide the id or the name of the group\")\n if group is None:\n return False\n if group.id == settings.SITH_GROUP_PUBLIC_ID:\n return True\n if group.id == settings.SITH_GROUP_SUBSCRIBERS_ID:\n return self.is_subscribed\n if group.id == settings.SITH_GROUP_OLD_SUBSCRIBERS_ID:\n return self.was_subscribed\n if group.id == settings.SITH_GROUP_ROOT_ID:\n return self.is_root\n if group.is_meta:\n # check if this group is associated with a club\n group.__class__ = MetaGroup\n club = group.associated_club\n if club is None:\n return False\n membership = club.get_membership_for(self)\n if membership is None:\n return False\n if group.name.endswith(settings.SITH_MEMBER_SUFFIX):\n return True\n return membership.role > settings.SITH_MAXIMUM_FREE_ROLE\n return group in self.cached_groups\n
"},{"location":"reference/launderette/models/#launderette.models.User.age","title":"age()
","text":"Return the age this user has the day the method is called. If the user has not filled his age, return 0.
Source code incore/models.py
@cached_property\ndef age(self) -> int:\n \"\"\"Return the age this user has the day the method is called.\n If the user has not filled his age, return 0.\n \"\"\"\n if self.date_of_birth is None:\n return 0\n today = timezone.now()\n age = today.year - self.date_of_birth.year\n # remove a year if this year's birthday is yet to come\n age -= (today.month, today.day) < (\n self.date_of_birth.month,\n self.date_of_birth.day,\n )\n return age\n
"},{"location":"reference/launderette/models/#launderette.models.User.get_full_name","title":"get_full_name()
","text":"Returns the first_name plus the last_name, with a space in between.
Source code incore/models.py
def get_full_name(self):\n \"\"\"Returns the first_name plus the last_name, with a space in between.\"\"\"\n full_name = \"%s %s\" % (self.first_name, self.last_name)\n return full_name.strip()\n
"},{"location":"reference/launderette/models/#launderette.models.User.get_short_name","title":"get_short_name()
","text":"Returns the short name for the user.
Source code incore/models.py
def get_short_name(self):\n \"\"\"Returns the short name for the user.\"\"\"\n if self.nick_name:\n return self.nick_name\n return self.first_name + \" \" + self.last_name\n
"},{"location":"reference/launderette/models/#launderette.models.User.get_display_name","title":"get_display_name()
","text":"Returns the display name of the user.
A nickname if possible, otherwise, the full name.
Source code incore/models.py
def get_display_name(self) -> str:\n \"\"\"Returns the display name of the user.\n\n A nickname if possible, otherwise, the full name.\n \"\"\"\n if self.nick_name:\n return \"%s (%s)\" % (self.get_full_name(), self.nick_name)\n return self.get_full_name()\n
"},{"location":"reference/launderette/models/#launderette.models.User.get_age","title":"get_age()
","text":"Returns the age.
Source code incore/models.py
def get_age(self):\n \"\"\"Returns the age.\"\"\"\n today = timezone.now()\n born = self.date_of_birth\n return (\n today.year - born.year - ((today.month, today.day) < (born.month, born.day))\n )\n
"},{"location":"reference/launderette/models/#launderette.models.User.get_family","title":"get_family(godfathers_depth=4, godchildren_depth=4)
","text":"Get the family of the user, with the given depth.
Parameters:
Name Type Description Defaultgodfathers_depth
NonNegativeInt
The number of generations of godfathers to fetch
4
godchildren_depth
NonNegativeInt
The number of generations of godchildren to fetch
4
Returns:
Type Descriptionset[through]
A list of family relationships in this user's family
Source code incore/models.py
def get_family(\n self,\n godfathers_depth: NonNegativeInt = 4,\n godchildren_depth: NonNegativeInt = 4,\n) -> set[User.godfathers.through]:\n \"\"\"Get the family of the user, with the given depth.\n\n Args:\n godfathers_depth: The number of generations of godfathers to fetch\n godchildren_depth: The number of generations of godchildren to fetch\n\n Returns:\n A list of family relationships in this user's family\n \"\"\"\n res = []\n for depth, key, reverse_key in [\n (godfathers_depth, \"from_user_id\", \"to_user_id\"),\n (godchildren_depth, \"to_user_id\", \"from_user_id\"),\n ]:\n if depth == 0:\n continue\n links = list(User.godfathers.through.objects.filter(**{key: self.id}))\n res.extend(links)\n for _ in range(1, depth): # noqa: F402 we don't care about gettext here\n ids = [getattr(c, reverse_key) for c in links]\n links = list(\n User.godfathers.through.objects.filter(\n **{f\"{key}__in\": ids}\n ).exclude(id__in=[r.id for r in res])\n )\n if not links:\n break\n res.extend(links)\n return set(res)\n
"},{"location":"reference/launderette/models/#launderette.models.User.email_user","title":"email_user(subject, message, from_email=None, **kwargs)
","text":"Sends an email to this User.
Source code incore/models.py
def email_user(self, subject, message, from_email=None, **kwargs):\n \"\"\"Sends an email to this User.\"\"\"\n if from_email is None:\n from_email = settings.DEFAULT_FROM_EMAIL\n send_mail(subject, message, from_email, [self.email], **kwargs)\n
"},{"location":"reference/launderette/models/#launderette.models.User.generate_username","title":"generate_username()
","text":"Generates a unique username based on the first and last names.
For example: Guy Carlier gives gcarlier, and gcarlier1 if the first one exists.
Returns:
Type Descriptionstr
The generated username.
Source code incore/models.py
def generate_username(self) -> str:\n \"\"\"Generates a unique username based on the first and last names.\n\n For example: Guy Carlier gives gcarlier, and gcarlier1 if the first one exists.\n\n Returns:\n The generated username.\n \"\"\"\n\n def remove_accents(data):\n return \"\".join(\n x\n for x in unicodedata.normalize(\"NFKD\", data)\n if unicodedata.category(x)[0] == \"L\"\n ).lower()\n\n user_name = (\n remove_accents(self.first_name[0] + self.last_name)\n .encode(\"ascii\", \"ignore\")\n .decode(\"utf-8\")\n )\n un_set = [u.username for u in User.objects.all()]\n if user_name in un_set:\n i = 1\n while user_name + str(i) in un_set:\n i += 1\n user_name += str(i)\n self.username = user_name\n return user_name\n
"},{"location":"reference/launderette/models/#launderette.models.User.is_owner","title":"is_owner(obj)
","text":"Determine if the object is owned by the user.
Source code incore/models.py
def is_owner(self, obj):\n \"\"\"Determine if the object is owned by the user.\"\"\"\n if hasattr(obj, \"is_owned_by\") and obj.is_owned_by(self):\n return True\n if hasattr(obj, \"owner_group\") and self.is_in_group(pk=obj.owner_group.id):\n return True\n return self.is_root\n
"},{"location":"reference/launderette/models/#launderette.models.User.can_edit","title":"can_edit(obj)
","text":"Determine if the object can be edited by the user.
Source code incore/models.py
def can_edit(self, obj):\n \"\"\"Determine if the object can be edited by the user.\"\"\"\n if hasattr(obj, \"can_be_edited_by\") and obj.can_be_edited_by(self):\n return True\n if hasattr(obj, \"edit_groups\"):\n for pk in obj.edit_groups.values_list(\"pk\", flat=True):\n if self.is_in_group(pk=pk):\n return True\n if isinstance(obj, User) and obj == self:\n return True\n return self.is_owner(obj)\n
"},{"location":"reference/launderette/models/#launderette.models.User.can_view","title":"can_view(obj)
","text":"Determine if the object can be viewed by the user.
Source code incore/models.py
def can_view(self, obj):\n \"\"\"Determine if the object can be viewed by the user.\"\"\"\n if hasattr(obj, \"can_be_viewed_by\") and obj.can_be_viewed_by(self):\n return True\n if hasattr(obj, \"view_groups\"):\n for pk in obj.view_groups.values_list(\"pk\", flat=True):\n if self.is_in_group(pk=pk):\n return True\n return self.can_edit(obj)\n
"},{"location":"reference/launderette/models/#launderette.models.User.clubs_with_rights","title":"clubs_with_rights()
","text":"The list of clubs where the user has rights
Source code incore/models.py
@cached_property\ndef clubs_with_rights(self) -> list[Club]:\n \"\"\"The list of clubs where the user has rights\"\"\"\n memberships = self.memberships.ongoing().board().select_related(\"club\")\n return [m.club for m in memberships]\n
"},{"location":"reference/launderette/models/#launderette.models.Counter","title":"Counter
","text":" Bases: Model
gen_token()
","text":"Generate a new token for this counter.
Source code incounter/models.py
def gen_token(self) -> None:\n \"\"\"Generate a new token for this counter.\"\"\"\n self.token = \"\".join(\n random.choice(string.ascii_letters + string.digits) for _ in range(30)\n )\n self.save()\n
"},{"location":"reference/launderette/models/#launderette.models.Counter.barmen_list","title":"barmen_list()
","text":"Returns the barman list as list of User.
Source code incounter/models.py
@cached_property\ndef barmen_list(self) -> list[User]:\n \"\"\"Returns the barman list as list of User.\"\"\"\n return [\n p.user for p in self.permanencies.filter(end=None).select_related(\"user\")\n ]\n
"},{"location":"reference/launderette/models/#launderette.models.Counter.get_random_barman","title":"get_random_barman()
","text":"Return a random user being currently a barman.
Source code incounter/models.py
def get_random_barman(self) -> User:\n \"\"\"Return a random user being currently a barman.\"\"\"\n return random.choice(self.barmen_list)\n
"},{"location":"reference/launderette/models/#launderette.models.Counter.update_activity","title":"update_activity()
","text":"Update the barman activity to prevent timeout.
Source code incounter/models.py
def update_activity(self) -> None:\n \"\"\"Update the barman activity to prevent timeout.\"\"\"\n self.permanencies.filter(end=None).update(activity=timezone.now())\n
"},{"location":"reference/launderette/models/#launderette.models.Counter.can_refill","title":"can_refill()
","text":"Show if the counter authorize the refilling with physic money.
Source code incounter/models.py
def can_refill(self) -> bool:\n \"\"\"Show if the counter authorize the refilling with physic money.\"\"\"\n if self.type != \"BAR\":\n return False\n if self.id in SITH_COUNTER_OFFICES:\n # If the counter is either 'AE' or 'BdF', refills are authorized\n return True\n # at least one of the barmen is in the AE board\n ae = Club.objects.get(unix_name=SITH_MAIN_CLUB[\"unix_name\"])\n return any(ae.get_membership_for(barman) for barman in self.barmen_list)\n
"},{"location":"reference/launderette/models/#launderette.models.Counter.get_top_barmen","title":"get_top_barmen()
","text":"Return a QuerySet querying the office hours stats of all the barmen of all time of this counter, ordered by descending number of hours.
Each element of the QuerySet corresponds to a barman and has the following datacounter/models.py
def get_top_barmen(self) -> QuerySet:\n \"\"\"Return a QuerySet querying the office hours stats of all the barmen of all time\n of this counter, ordered by descending number of hours.\n\n Each element of the QuerySet corresponds to a barman and has the following data :\n - the full name (first name + last name) of the barman\n - the nickname of the barman\n - the promo of the barman\n - the total number of office hours the barman did attend\n \"\"\"\n return (\n self.permanencies.exclude(end=None)\n .annotate(\n name=Concat(F(\"user__first_name\"), Value(\" \"), F(\"user__last_name\"))\n )\n .annotate(nickname=F(\"user__nick_name\"))\n .annotate(promo=F(\"user__promo\"))\n .values(\"user\", \"name\", \"nickname\", \"promo\")\n .annotate(perm_sum=Sum(F(\"end\") - F(\"start\")))\n .exclude(perm_sum=None)\n .order_by(\"-perm_sum\")\n )\n
"},{"location":"reference/launderette/models/#launderette.models.Counter.get_top_customers","title":"get_top_customers(since=None)
","text":"Return a QuerySet querying the money spent by customers of this counter since the specified date, ordered by descending amount of money spent.
Each element of the QuerySet corresponds to a customer and has the following data :
Parameters:
Name Type Description Defaultsince
datetime | date | None
timestamp from which to perform the calculation
None
Source code in counter/models.py
def get_top_customers(self, since: datetime | date | None = None) -> QuerySet:\n \"\"\"Return a QuerySet querying the money spent by customers of this counter\n since the specified date, ordered by descending amount of money spent.\n\n Each element of the QuerySet corresponds to a customer and has the following data :\n\n - the full name (first name + last name) of the customer\n - the nickname of the customer\n - the amount of money spent by the customer\n\n Args:\n since: timestamp from which to perform the calculation\n \"\"\"\n if since is None:\n since = get_start_of_semester()\n if isinstance(since, date):\n since = datetime(since.year, since.month, since.day, tzinfo=tz.utc)\n return (\n self.sellings.filter(date__gte=since)\n .annotate(\n name=Concat(\n F(\"customer__user__first_name\"),\n Value(\" \"),\n F(\"customer__user__last_name\"),\n )\n )\n .annotate(nickname=F(\"customer__user__nick_name\"))\n .annotate(promo=F(\"customer__user__promo\"))\n .annotate(user=F(\"customer__user\"))\n .values(\"user\", \"promo\", \"name\", \"nickname\")\n .annotate(\n selling_sum=Sum(\n F(\"unit_price\") * F(\"quantity\"), output_field=CurrencyField()\n )\n )\n .filter(selling_sum__gt=0)\n .order_by(\"-selling_sum\")\n )\n
"},{"location":"reference/launderette/models/#launderette.models.Counter.get_total_sales","title":"get_total_sales(since=None)
","text":"Compute and return the total turnover of this counter since the given date.
By default, the date is the start of the current semester.
Parameters:
Name Type Description Defaultsince
datetime | date | None
timestamp from which to perform the calculation
None
Returns:
Type DescriptionCurrencyField
Total revenue earned at this counter.
Source code incounter/models.py
def get_total_sales(self, since: datetime | date | None = None) -> CurrencyField:\n \"\"\"Compute and return the total turnover of this counter since the given date.\n\n By default, the date is the start of the current semester.\n\n Args:\n since: timestamp from which to perform the calculation\n\n Returns:\n Total revenue earned at this counter.\n \"\"\"\n if since is None:\n since = get_start_of_semester()\n if isinstance(since, date):\n since = datetime(since.year, since.month, since.day, tzinfo=tz.utc)\n return self.sellings.filter(date__gte=since).aggregate(\n total=Sum(\n F(\"quantity\") * F(\"unit_price\"),\n default=0,\n output_field=CurrencyField(),\n )\n )[\"total\"]\n
"},{"location":"reference/launderette/models/#launderette.models.Launderette","title":"Launderette
","text":" Bases: Model
is_owned_by(user)
","text":"Method to see if that object can be edited by the given user.
Source code inlaunderette/models.py
def is_owned_by(self, user):\n \"\"\"Method to see if that object can be edited by the given user.\"\"\"\n if user.is_anonymous:\n return False\n launderette_club = Club.objects.filter(\n unix_name=settings.SITH_LAUNDERETTE_MANAGER[\"unix_name\"]\n ).first()\n m = launderette_club.get_membership_for(user)\n return bool(m and m.role >= 9)\n
"},{"location":"reference/launderette/models/#launderette.models.Machine","title":"Machine
","text":" Bases: Model
is_owned_by(user)
","text":"Method to see if that object can be edited by the given user.
Source code inlaunderette/models.py
def is_owned_by(self, user):\n \"\"\"Method to see if that object can be edited by the given user.\"\"\"\n if user.is_anonymous:\n return False\n launderette_club = Club.objects.filter(\n unix_name=settings.SITH_LAUNDERETTE_MANAGER[\"unix_name\"]\n ).first()\n m = launderette_club.get_membership_for(user)\n return bool(m and m.role >= 9)\n
"},{"location":"reference/launderette/models/#launderette.models.Token","title":"Token
","text":" Bases: Model
is_owned_by(user)
","text":"Method to see if that object can be edited by the given user.
Source code inlaunderette/models.py
def is_owned_by(self, user):\n \"\"\"Method to see if that object can be edited by the given user.\"\"\"\n if user.is_anonymous:\n return False\n launderette_club = Club.objects.filter(\n unix_name=settings.SITH_LAUNDERETTE_MANAGER[\"unix_name\"]\n ).first()\n m = launderette_club.get_membership_for(user)\n return bool(m and m.role >= 9)\n
"},{"location":"reference/launderette/models/#launderette.models.Slot","title":"Slot
","text":" Bases: Model
Club
","text":" Bases: Model
The Club class, made as a tree to allow nice tidy organization.
"},{"location":"reference/launderette/views/#launderette.views.Club.check_loop","title":"check_loop()
","text":"Raise a validation error when a loop is found within the parent list.
Source code inclub/models.py
def check_loop(self):\n \"\"\"Raise a validation error when a loop is found within the parent list.\"\"\"\n objs = []\n cur = self\n while cur.parent is not None:\n if cur in objs:\n raise ValidationError(_(\"You can not make loops in clubs\"))\n objs.append(cur)\n cur = cur.parent\n
"},{"location":"reference/launderette/views/#launderette.views.Club.is_owned_by","title":"is_owned_by(user)
","text":"Method to see if that object can be super edited by the given user.
Source code inclub/models.py
def is_owned_by(self, user):\n \"\"\"Method to see if that object can be super edited by the given user.\"\"\"\n if user.is_anonymous:\n return False\n return user.is_board_member\n
"},{"location":"reference/launderette/views/#launderette.views.Club.can_be_edited_by","title":"can_be_edited_by(user)
","text":"Method to see if that object can be edited by the given user.
Source code inclub/models.py
def can_be_edited_by(self, user):\n \"\"\"Method to see if that object can be edited by the given user.\"\"\"\n return self.has_rights_in_club(user)\n
"},{"location":"reference/launderette/views/#launderette.views.Club.can_be_viewed_by","title":"can_be_viewed_by(user)
","text":"Method to see if that object can be seen by the given user.
Source code inclub/models.py
def can_be_viewed_by(self, user):\n \"\"\"Method to see if that object can be seen by the given user.\"\"\"\n sub = User.objects.filter(pk=user.pk).first()\n if sub is None:\n return False\n return sub.was_subscribed\n
"},{"location":"reference/launderette/views/#launderette.views.Club.get_membership_for","title":"get_membership_for(user)
","text":"Return the current membership the given user.
NoteThe result is cached.
Source code inclub/models.py
def get_membership_for(self, user: User) -> Membership | None:\n \"\"\"Return the current membership the given user.\n\n Note:\n The result is cached.\n \"\"\"\n if user.is_anonymous:\n return None\n membership = cache.get(f\"membership_{self.id}_{user.id}\")\n if membership == \"not_member\":\n return None\n if membership is None:\n membership = self.members.filter(user=user, end_date=None).first()\n if membership is None:\n cache.set(f\"membership_{self.id}_{user.id}\", \"not_member\")\n else:\n cache.set(f\"membership_{self.id}_{user.id}\", membership)\n return membership\n
"},{"location":"reference/launderette/views/#launderette.views.Page","title":"Page
","text":" Bases: Model
The page class to build a Wiki Each page may have a parent and it's URL is of the form my.site/page/// It has an ID field, but don't use it, since it's only there for DB part, and because compound primary key is awkward! Prefere querying pages with Page.get_page_by_full_name().
Be careful with the _full_name attribute: this field may not be valid until you call save(). It's made for fast query, but don't rely on it when playing with a Page object, use get_full_name() instead!
"},{"location":"reference/launderette/views/#launderette.views.Page.save","title":"save(*args, **kwargs)
","text":"Performs some needed actions before and after saving a page in database.
Source code incore/models.py
def save(self, *args, **kwargs):\n \"\"\"Performs some needed actions before and after saving a page in database.\"\"\"\n locked = kwargs.pop(\"force_lock\", False)\n if not locked:\n locked = self.is_locked()\n if not locked:\n raise NotLocked(\"The page is not locked and thus can not be saved\")\n self.full_clean()\n if not self.id:\n super().save(\n *args, **kwargs\n ) # Save a first time to correctly set _full_name\n # This reset the _full_name just before saving to maintain a coherent field quicker for queries than the\n # recursive method\n # It also update all the children to maintain correct names\n self._full_name = self.get_full_name()\n for c in self.children.all():\n c.save()\n super().save(*args, **kwargs)\n self.unset_lock()\n
"},{"location":"reference/launderette/views/#launderette.views.Page.get_page_by_full_name","title":"get_page_by_full_name(name)
staticmethod
","text":"Quicker to get a page with that method rather than building the request every time.
Source code incore/models.py
@staticmethod\ndef get_page_by_full_name(name):\n \"\"\"Quicker to get a page with that method rather than building the request every time.\"\"\"\n return Page.objects.filter(_full_name=name).first()\n
"},{"location":"reference/launderette/views/#launderette.views.Page.clean","title":"clean()
","text":"Cleans up only the name for the moment, but this can be used to make any treatment before saving the object.
Source code incore/models.py
def clean(self):\n \"\"\"Cleans up only the name for the moment, but this can be used to make any treatment before saving the object.\"\"\"\n if \"/\" in self.name:\n self.name = self.name.split(\"/\")[-1]\n if (\n Page.objects.exclude(pk=self.pk)\n .filter(_full_name=self.get_full_name())\n .exists()\n ):\n raise ValidationError(_(\"Duplicate page\"), code=\"duplicate\")\n super().clean()\n if self.parent is not None and self in self.get_parent_list():\n raise ValidationError(_(\"Loop in page tree\"), code=\"loop\")\n
"},{"location":"reference/launderette/views/#launderette.views.Page.is_locked","title":"is_locked()
","text":"Is True if the page is locked, False otherwise.
This is where the timeout is handled, so a locked page for which the timeout is reach will be unlocked and this function will return False.
Source code incore/models.py
def is_locked(self):\n \"\"\"Is True if the page is locked, False otherwise.\n\n This is where the timeout is handled,\n so a locked page for which the timeout is reach will be unlocked and this\n function will return False.\n \"\"\"\n if self.lock_timeout and (\n timezone.now() - self.lock_timeout > timedelta(minutes=5)\n ):\n self.unset_lock()\n return (\n self.lock_user\n and self.lock_timeout\n and (timezone.now() - self.lock_timeout < timedelta(minutes=5))\n )\n
"},{"location":"reference/launderette/views/#launderette.views.Page.set_lock","title":"set_lock(user)
","text":"Sets a lock on the current page or raise an AlreadyLocked exception.
Source code incore/models.py
def set_lock(self, user):\n \"\"\"Sets a lock on the current page or raise an AlreadyLocked exception.\"\"\"\n if self.is_locked() and self.get_lock() != user:\n raise AlreadyLocked(\"The page is already locked by someone else\")\n self.lock_user = user\n self.lock_timeout = timezone.now()\n super().save()\n
"},{"location":"reference/launderette/views/#launderette.views.Page.set_lock_recursive","title":"set_lock_recursive(user)
","text":"Locks recursively all the child pages for editing properties.
Source code incore/models.py
def set_lock_recursive(self, user):\n \"\"\"Locks recursively all the child pages for editing properties.\"\"\"\n for p in self.children.all():\n p.set_lock_recursive(user)\n self.set_lock(user)\n
"},{"location":"reference/launderette/views/#launderette.views.Page.unset_lock_recursive","title":"unset_lock_recursive()
","text":"Unlocks recursively all the child pages.
Source code incore/models.py
def unset_lock_recursive(self):\n \"\"\"Unlocks recursively all the child pages.\"\"\"\n for p in self.children.all():\n p.unset_lock_recursive()\n self.unset_lock()\n
"},{"location":"reference/launderette/views/#launderette.views.Page.unset_lock","title":"unset_lock()
","text":"Always try to unlock, even if there is no lock.
Source code incore/models.py
def unset_lock(self):\n \"\"\"Always try to unlock, even if there is no lock.\"\"\"\n self.lock_user = None\n self.lock_timeout = None\n super().save()\n
"},{"location":"reference/launderette/views/#launderette.views.Page.get_lock","title":"get_lock()
","text":"Returns the page's mutex containing the time and the user in a dict.
Source code incore/models.py
def get_lock(self):\n \"\"\"Returns the page's mutex containing the time and the user in a dict.\"\"\"\n if self.lock_user:\n return self.lock_user\n raise NotLocked(\"The page is not locked and thus can not return its user\")\n
"},{"location":"reference/launderette/views/#launderette.views.Page.get_full_name","title":"get_full_name()
","text":"Computes the real full_name of the page based on its name and its parent's name You can and must rely on this function when working on a page object that is not freshly fetched from the DB (For example when treating a Page object coming from a form).
Source code incore/models.py
def get_full_name(self):\n \"\"\"Computes the real full_name of the page based on its name and its parent's name\n You can and must rely on this function when working on a page object that is not freshly fetched from the DB\n (For example when treating a Page object coming from a form).\n \"\"\"\n if self.parent is None:\n return self.name\n return f\"{self.parent.get_full_name()}/{self.name}\"\n
"},{"location":"reference/launderette/views/#launderette.views.User","title":"User
","text":" Bases: AbstractBaseUser
Defines the base user class, useable in every app.
This is almost the same as the auth module AbstractUser since it inherits from it, but some fields are required, and the username is generated automatically with the name of the user (see generate_username()).
Added field: nick_name, date_of_birth Required fields: email, first_name, last_name, date_of_birth
"},{"location":"reference/launderette/views/#launderette.views.User.cached_groups","title":"cached_groups: list[Group]
property
","text":"Get the list of groups this user is in.
The result is cached for the default duration (should be 5 minutes)
Returns: A list of all the groups this user is in.
"},{"location":"reference/launderette/views/#launderette.views.User.is_in_group","title":"is_in_group(*, pk=None, name=None)
","text":"Check if this user is in the given group. Either a group id or a group name must be provided. If both are passed, only the id will be considered.
The group will be fetched using the given parameter. If no group is found, return False. If a group is found, check if this user is in the latter.
Returns:
Type Descriptionbool
True if the user is the group, else False
Source code incore/models.py
def is_in_group(self, *, pk: int | None = None, name: str | None = None) -> bool:\n \"\"\"Check if this user is in the given group.\n Either a group id or a group name must be provided.\n If both are passed, only the id will be considered.\n\n The group will be fetched using the given parameter.\n If no group is found, return False.\n If a group is found, check if this user is in the latter.\n\n Returns:\n True if the user is the group, else False\n \"\"\"\n if pk is not None:\n group: Optional[Group] = get_group(pk=pk)\n elif name is not None:\n group: Optional[Group] = get_group(name=name)\n else:\n raise ValueError(\"You must either provide the id or the name of the group\")\n if group is None:\n return False\n if group.id == settings.SITH_GROUP_PUBLIC_ID:\n return True\n if group.id == settings.SITH_GROUP_SUBSCRIBERS_ID:\n return self.is_subscribed\n if group.id == settings.SITH_GROUP_OLD_SUBSCRIBERS_ID:\n return self.was_subscribed\n if group.id == settings.SITH_GROUP_ROOT_ID:\n return self.is_root\n if group.is_meta:\n # check if this group is associated with a club\n group.__class__ = MetaGroup\n club = group.associated_club\n if club is None:\n return False\n membership = club.get_membership_for(self)\n if membership is None:\n return False\n if group.name.endswith(settings.SITH_MEMBER_SUFFIX):\n return True\n return membership.role > settings.SITH_MAXIMUM_FREE_ROLE\n return group in self.cached_groups\n
"},{"location":"reference/launderette/views/#launderette.views.User.age","title":"age()
","text":"Return the age this user has the day the method is called. If the user has not filled his age, return 0.
Source code incore/models.py
@cached_property\ndef age(self) -> int:\n \"\"\"Return the age this user has the day the method is called.\n If the user has not filled his age, return 0.\n \"\"\"\n if self.date_of_birth is None:\n return 0\n today = timezone.now()\n age = today.year - self.date_of_birth.year\n # remove a year if this year's birthday is yet to come\n age -= (today.month, today.day) < (\n self.date_of_birth.month,\n self.date_of_birth.day,\n )\n return age\n
"},{"location":"reference/launderette/views/#launderette.views.User.get_full_name","title":"get_full_name()
","text":"Returns the first_name plus the last_name, with a space in between.
Source code incore/models.py
def get_full_name(self):\n \"\"\"Returns the first_name plus the last_name, with a space in between.\"\"\"\n full_name = \"%s %s\" % (self.first_name, self.last_name)\n return full_name.strip()\n
"},{"location":"reference/launderette/views/#launderette.views.User.get_short_name","title":"get_short_name()
","text":"Returns the short name for the user.
Source code incore/models.py
def get_short_name(self):\n \"\"\"Returns the short name for the user.\"\"\"\n if self.nick_name:\n return self.nick_name\n return self.first_name + \" \" + self.last_name\n
"},{"location":"reference/launderette/views/#launderette.views.User.get_display_name","title":"get_display_name()
","text":"Returns the display name of the user.
A nickname if possible, otherwise, the full name.
Source code incore/models.py
def get_display_name(self) -> str:\n \"\"\"Returns the display name of the user.\n\n A nickname if possible, otherwise, the full name.\n \"\"\"\n if self.nick_name:\n return \"%s (%s)\" % (self.get_full_name(), self.nick_name)\n return self.get_full_name()\n
"},{"location":"reference/launderette/views/#launderette.views.User.get_age","title":"get_age()
","text":"Returns the age.
Source code incore/models.py
def get_age(self):\n \"\"\"Returns the age.\"\"\"\n today = timezone.now()\n born = self.date_of_birth\n return (\n today.year - born.year - ((today.month, today.day) < (born.month, born.day))\n )\n
"},{"location":"reference/launderette/views/#launderette.views.User.get_family","title":"get_family(godfathers_depth=4, godchildren_depth=4)
","text":"Get the family of the user, with the given depth.
Parameters:
Name Type Description Defaultgodfathers_depth
NonNegativeInt
The number of generations of godfathers to fetch
4
godchildren_depth
NonNegativeInt
The number of generations of godchildren to fetch
4
Returns:
Type Descriptionset[through]
A list of family relationships in this user's family
Source code incore/models.py
def get_family(\n self,\n godfathers_depth: NonNegativeInt = 4,\n godchildren_depth: NonNegativeInt = 4,\n) -> set[User.godfathers.through]:\n \"\"\"Get the family of the user, with the given depth.\n\n Args:\n godfathers_depth: The number of generations of godfathers to fetch\n godchildren_depth: The number of generations of godchildren to fetch\n\n Returns:\n A list of family relationships in this user's family\n \"\"\"\n res = []\n for depth, key, reverse_key in [\n (godfathers_depth, \"from_user_id\", \"to_user_id\"),\n (godchildren_depth, \"to_user_id\", \"from_user_id\"),\n ]:\n if depth == 0:\n continue\n links = list(User.godfathers.through.objects.filter(**{key: self.id}))\n res.extend(links)\n for _ in range(1, depth): # noqa: F402 we don't care about gettext here\n ids = [getattr(c, reverse_key) for c in links]\n links = list(\n User.godfathers.through.objects.filter(\n **{f\"{key}__in\": ids}\n ).exclude(id__in=[r.id for r in res])\n )\n if not links:\n break\n res.extend(links)\n return set(res)\n
"},{"location":"reference/launderette/views/#launderette.views.User.email_user","title":"email_user(subject, message, from_email=None, **kwargs)
","text":"Sends an email to this User.
Source code incore/models.py
def email_user(self, subject, message, from_email=None, **kwargs):\n \"\"\"Sends an email to this User.\"\"\"\n if from_email is None:\n from_email = settings.DEFAULT_FROM_EMAIL\n send_mail(subject, message, from_email, [self.email], **kwargs)\n
"},{"location":"reference/launderette/views/#launderette.views.User.generate_username","title":"generate_username()
","text":"Generates a unique username based on the first and last names.
For example: Guy Carlier gives gcarlier, and gcarlier1 if the first one exists.
Returns:
Type Descriptionstr
The generated username.
Source code incore/models.py
def generate_username(self) -> str:\n \"\"\"Generates a unique username based on the first and last names.\n\n For example: Guy Carlier gives gcarlier, and gcarlier1 if the first one exists.\n\n Returns:\n The generated username.\n \"\"\"\n\n def remove_accents(data):\n return \"\".join(\n x\n for x in unicodedata.normalize(\"NFKD\", data)\n if unicodedata.category(x)[0] == \"L\"\n ).lower()\n\n user_name = (\n remove_accents(self.first_name[0] + self.last_name)\n .encode(\"ascii\", \"ignore\")\n .decode(\"utf-8\")\n )\n un_set = [u.username for u in User.objects.all()]\n if user_name in un_set:\n i = 1\n while user_name + str(i) in un_set:\n i += 1\n user_name += str(i)\n self.username = user_name\n return user_name\n
"},{"location":"reference/launderette/views/#launderette.views.User.is_owner","title":"is_owner(obj)
","text":"Determine if the object is owned by the user.
Source code incore/models.py
def is_owner(self, obj):\n \"\"\"Determine if the object is owned by the user.\"\"\"\n if hasattr(obj, \"is_owned_by\") and obj.is_owned_by(self):\n return True\n if hasattr(obj, \"owner_group\") and self.is_in_group(pk=obj.owner_group.id):\n return True\n return self.is_root\n
"},{"location":"reference/launderette/views/#launderette.views.User.can_edit","title":"can_edit(obj)
","text":"Determine if the object can be edited by the user.
Source code incore/models.py
def can_edit(self, obj):\n \"\"\"Determine if the object can be edited by the user.\"\"\"\n if hasattr(obj, \"can_be_edited_by\") and obj.can_be_edited_by(self):\n return True\n if hasattr(obj, \"edit_groups\"):\n for pk in obj.edit_groups.values_list(\"pk\", flat=True):\n if self.is_in_group(pk=pk):\n return True\n if isinstance(obj, User) and obj == self:\n return True\n return self.is_owner(obj)\n
"},{"location":"reference/launderette/views/#launderette.views.User.can_view","title":"can_view(obj)
","text":"Determine if the object can be viewed by the user.
Source code incore/models.py
def can_view(self, obj):\n \"\"\"Determine if the object can be viewed by the user.\"\"\"\n if hasattr(obj, \"can_be_viewed_by\") and obj.can_be_viewed_by(self):\n return True\n if hasattr(obj, \"view_groups\"):\n for pk in obj.view_groups.values_list(\"pk\", flat=True):\n if self.is_in_group(pk=pk):\n return True\n return self.can_edit(obj)\n
"},{"location":"reference/launderette/views/#launderette.views.User.clubs_with_rights","title":"clubs_with_rights()
","text":"The list of clubs where the user has rights
Source code incore/models.py
@cached_property\ndef clubs_with_rights(self) -> list[Club]:\n \"\"\"The list of clubs where the user has rights\"\"\"\n memberships = self.memberships.ongoing().board().select_related(\"club\")\n return [m.club for m in memberships]\n
"},{"location":"reference/launderette/views/#launderette.views.CanCreateMixin","title":"CanCreateMixin
","text":" Bases: View
Protect any child view that would create an object.
Raises:
Type DescriptionPermissionDenied
If the user has not the necessary permission to create the object of the view.
"},{"location":"reference/launderette/views/#launderette.views.CanEditMixin","title":"CanEditMixin
","text":" Bases: GenericContentPermissionMixinBuilder
Ensure the user has permission to edit this view's object.
Raises:
Type DescriptionPermissionDenied
if the user cannot edit this view's object.
"},{"location":"reference/launderette/views/#launderette.views.CanEditPropMixin","title":"CanEditPropMixin
","text":" Bases: GenericContentPermissionMixinBuilder
Ensure the user has owner permissions on the child view object.
In other word, you can make a view with this view as parent, and it will be retricted to the users that are in the object's owner_group or that pass the obj.can_be_viewed_by
test.
Raises:
Type DescriptionPermissionDenied
If the user cannot see the object
"},{"location":"reference/launderette/views/#launderette.views.CanViewMixin","title":"CanViewMixin
","text":" Bases: GenericContentPermissionMixinBuilder
Ensure the user has permission to view this view's object.
Raises:
Type DescriptionPermissionDenied
if the user cannot edit this view's object.
"},{"location":"reference/launderette/views/#launderette.views.GetUserForm","title":"GetUserForm
","text":" Bases: Form
The Form class aims at providing a valid user_id field in its cleaned data, in order to pass it to some view, reverse function, or any other use.
The Form implements a nice JS widget allowing the user to type a customer account id, or search the database with some nickname, first name, or last name (TODO)
"},{"location":"reference/launderette/views/#launderette.views.Counter","title":"Counter
","text":" Bases: Model
gen_token()
","text":"Generate a new token for this counter.
Source code incounter/models.py
def gen_token(self) -> None:\n \"\"\"Generate a new token for this counter.\"\"\"\n self.token = \"\".join(\n random.choice(string.ascii_letters + string.digits) for _ in range(30)\n )\n self.save()\n
"},{"location":"reference/launderette/views/#launderette.views.Counter.barmen_list","title":"barmen_list()
","text":"Returns the barman list as list of User.
Source code incounter/models.py
@cached_property\ndef barmen_list(self) -> list[User]:\n \"\"\"Returns the barman list as list of User.\"\"\"\n return [\n p.user for p in self.permanencies.filter(end=None).select_related(\"user\")\n ]\n
"},{"location":"reference/launderette/views/#launderette.views.Counter.get_random_barman","title":"get_random_barman()
","text":"Return a random user being currently a barman.
Source code incounter/models.py
def get_random_barman(self) -> User:\n \"\"\"Return a random user being currently a barman.\"\"\"\n return random.choice(self.barmen_list)\n
"},{"location":"reference/launderette/views/#launderette.views.Counter.update_activity","title":"update_activity()
","text":"Update the barman activity to prevent timeout.
Source code incounter/models.py
def update_activity(self) -> None:\n \"\"\"Update the barman activity to prevent timeout.\"\"\"\n self.permanencies.filter(end=None).update(activity=timezone.now())\n
"},{"location":"reference/launderette/views/#launderette.views.Counter.can_refill","title":"can_refill()
","text":"Show if the counter authorize the refilling with physic money.
Source code incounter/models.py
def can_refill(self) -> bool:\n \"\"\"Show if the counter authorize the refilling with physic money.\"\"\"\n if self.type != \"BAR\":\n return False\n if self.id in SITH_COUNTER_OFFICES:\n # If the counter is either 'AE' or 'BdF', refills are authorized\n return True\n # at least one of the barmen is in the AE board\n ae = Club.objects.get(unix_name=SITH_MAIN_CLUB[\"unix_name\"])\n return any(ae.get_membership_for(barman) for barman in self.barmen_list)\n
"},{"location":"reference/launderette/views/#launderette.views.Counter.get_top_barmen","title":"get_top_barmen()
","text":"Return a QuerySet querying the office hours stats of all the barmen of all time of this counter, ordered by descending number of hours.
Each element of the QuerySet corresponds to a barman and has the following datacounter/models.py
def get_top_barmen(self) -> QuerySet:\n \"\"\"Return a QuerySet querying the office hours stats of all the barmen of all time\n of this counter, ordered by descending number of hours.\n\n Each element of the QuerySet corresponds to a barman and has the following data :\n - the full name (first name + last name) of the barman\n - the nickname of the barman\n - the promo of the barman\n - the total number of office hours the barman did attend\n \"\"\"\n return (\n self.permanencies.exclude(end=None)\n .annotate(\n name=Concat(F(\"user__first_name\"), Value(\" \"), F(\"user__last_name\"))\n )\n .annotate(nickname=F(\"user__nick_name\"))\n .annotate(promo=F(\"user__promo\"))\n .values(\"user\", \"name\", \"nickname\", \"promo\")\n .annotate(perm_sum=Sum(F(\"end\") - F(\"start\")))\n .exclude(perm_sum=None)\n .order_by(\"-perm_sum\")\n )\n
"},{"location":"reference/launderette/views/#launderette.views.Counter.get_top_customers","title":"get_top_customers(since=None)
","text":"Return a QuerySet querying the money spent by customers of this counter since the specified date, ordered by descending amount of money spent.
Each element of the QuerySet corresponds to a customer and has the following data :
Parameters:
Name Type Description Defaultsince
datetime | date | None
timestamp from which to perform the calculation
None
Source code in counter/models.py
def get_top_customers(self, since: datetime | date | None = None) -> QuerySet:\n \"\"\"Return a QuerySet querying the money spent by customers of this counter\n since the specified date, ordered by descending amount of money spent.\n\n Each element of the QuerySet corresponds to a customer and has the following data :\n\n - the full name (first name + last name) of the customer\n - the nickname of the customer\n - the amount of money spent by the customer\n\n Args:\n since: timestamp from which to perform the calculation\n \"\"\"\n if since is None:\n since = get_start_of_semester()\n if isinstance(since, date):\n since = datetime(since.year, since.month, since.day, tzinfo=tz.utc)\n return (\n self.sellings.filter(date__gte=since)\n .annotate(\n name=Concat(\n F(\"customer__user__first_name\"),\n Value(\" \"),\n F(\"customer__user__last_name\"),\n )\n )\n .annotate(nickname=F(\"customer__user__nick_name\"))\n .annotate(promo=F(\"customer__user__promo\"))\n .annotate(user=F(\"customer__user\"))\n .values(\"user\", \"promo\", \"name\", \"nickname\")\n .annotate(\n selling_sum=Sum(\n F(\"unit_price\") * F(\"quantity\"), output_field=CurrencyField()\n )\n )\n .filter(selling_sum__gt=0)\n .order_by(\"-selling_sum\")\n )\n
"},{"location":"reference/launderette/views/#launderette.views.Counter.get_total_sales","title":"get_total_sales(since=None)
","text":"Compute and return the total turnover of this counter since the given date.
By default, the date is the start of the current semester.
Parameters:
Name Type Description Defaultsince
datetime | date | None
timestamp from which to perform the calculation
None
Returns:
Type DescriptionCurrencyField
Total revenue earned at this counter.
Source code incounter/models.py
def get_total_sales(self, since: datetime | date | None = None) -> CurrencyField:\n \"\"\"Compute and return the total turnover of this counter since the given date.\n\n By default, the date is the start of the current semester.\n\n Args:\n since: timestamp from which to perform the calculation\n\n Returns:\n Total revenue earned at this counter.\n \"\"\"\n if since is None:\n since = get_start_of_semester()\n if isinstance(since, date):\n since = datetime(since.year, since.month, since.day, tzinfo=tz.utc)\n return self.sellings.filter(date__gte=since).aggregate(\n total=Sum(\n F(\"quantity\") * F(\"unit_price\"),\n default=0,\n output_field=CurrencyField(),\n )\n )[\"total\"]\n
"},{"location":"reference/launderette/views/#launderette.views.Customer","title":"Customer
","text":" Bases: Model
Customer data of a User.
It adds some basic customers' information, such as the account ID, and is used by other accounting classes as reference to the customer, rather than using User.
"},{"location":"reference/launderette/views/#launderette.views.Customer.can_buy","title":"can_buy: bool
property
","text":"Check if whether this customer has the right to purchase any item.
This must be not confused with the Product.can_be_sold_to(user) method as the present method returns an information about a customer whereas the other tells something about the relation between a User (not a Customer, don't mix them) and a Product.
"},{"location":"reference/launderette/views/#launderette.views.Customer.save","title":"save(*args, allow_negative=False, is_selling=False, **kwargs)
","text":"is_selling : tell if the current action is a selling allow_negative : ignored if not a selling. Allow a selling to put the account in negative Those two parameters avoid blocking the save method of a customer if his account is negative.
Source code incounter/models.py
def save(self, *args, allow_negative=False, is_selling=False, **kwargs):\n \"\"\"is_selling : tell if the current action is a selling\n allow_negative : ignored if not a selling. Allow a selling to put the account in negative\n Those two parameters avoid blocking the save method of a customer if his account is negative.\n \"\"\"\n if self.amount < 0 and (is_selling and not allow_negative):\n raise ValidationError(_(\"Not enough money\"))\n super().save(*args, **kwargs)\n
"},{"location":"reference/launderette/views/#launderette.views.Customer.get_or_create","title":"get_or_create(user)
classmethod
","text":"Work in pretty much the same way as the usual get_or_create method, but with the default field replaced by some under the hood.
If the user has an account, return it as is. Else create a new account with no money on it and a new unique account id
Example : ::
user = User.objects.get(pk=1)\naccount, created = Customer.get_or_create(user)\nif created:\n print(f\"created a new account with id {account.id}\")\nelse:\n print(f\"user has already an account, with {account.id} \u20ac on it\"\n
Source code in counter/models.py
@classmethod\ndef get_or_create(cls, user: User) -> Tuple[Customer, bool]:\n \"\"\"Work in pretty much the same way as the usual get_or_create method,\n but with the default field replaced by some under the hood.\n\n If the user has an account, return it as is.\n Else create a new account with no money on it and a new unique account id\n\n Example : ::\n\n user = User.objects.get(pk=1)\n account, created = Customer.get_or_create(user)\n if created:\n print(f\"created a new account with id {account.id}\")\n else:\n print(f\"user has already an account, with {account.id} \u20ac on it\"\n \"\"\"\n if hasattr(user, \"customer\"):\n return user.customer, False\n\n # account_id are always a number with a letter appended\n account_id = (\n Customer.objects.order_by(Length(\"account_id\"), \"account_id\")\n .values(\"account_id\")\n .last()\n )\n if account_id is None:\n # legacy from the old site\n account = cls.objects.create(user=user, account_id=\"1504a\")\n return account, True\n\n account_id = account_id[\"account_id\"]\n account_num = int(account_id[:-1])\n while Customer.objects.filter(account_id=account_id).exists():\n # when entering the first iteration, we are using an already existing account id\n # so the loop should always execute at least one time\n account_num += 1\n account_id = f\"{account_num}{random.choice(string.ascii_lowercase)}\"\n\n account = cls.objects.create(user=user, account_id=account_id)\n return account, True\n
"},{"location":"reference/launderette/views/#launderette.views.Selling","title":"Selling
","text":" Bases: Model
Handle the sellings.
"},{"location":"reference/launderette/views/#launderette.views.Selling.save","title":"save(*args, allow_negative=False, **kwargs)
","text":"allow_negative : Allow this selling to use more money than available for this user.
Source code incounter/models.py
def save(self, *args, allow_negative=False, **kwargs):\n \"\"\"allow_negative : Allow this selling to use more money than available for this user.\"\"\"\n if not self.date:\n self.date = timezone.now()\n self.full_clean()\n if not self.is_validated:\n self.customer.amount -= self.quantity * self.unit_price\n self.customer.save(allow_negative=allow_negative, is_selling=True)\n self.is_validated = True\n user = self.customer.user\n if user.was_subscribed:\n if (\n self.product\n and self.product.id == settings.SITH_PRODUCT_SUBSCRIPTION_ONE_SEMESTER\n ):\n sub = Subscription(\n member=user,\n subscription_type=\"un-semestre\",\n payment_method=\"EBOUTIC\",\n location=\"EBOUTIC\",\n )\n sub.subscription_start = Subscription.compute_start()\n sub.subscription_start = Subscription.compute_start(\n duration=settings.SITH_SUBSCRIPTIONS[sub.subscription_type][\n \"duration\"\n ]\n )\n sub.subscription_end = Subscription.compute_end(\n duration=settings.SITH_SUBSCRIPTIONS[sub.subscription_type][\n \"duration\"\n ],\n start=sub.subscription_start,\n )\n sub.save()\n elif (\n self.product\n and self.product.id == settings.SITH_PRODUCT_SUBSCRIPTION_TWO_SEMESTERS\n ):\n sub = Subscription(\n member=user,\n subscription_type=\"deux-semestres\",\n payment_method=\"EBOUTIC\",\n location=\"EBOUTIC\",\n )\n sub.subscription_start = Subscription.compute_start()\n sub.subscription_start = Subscription.compute_start(\n duration=settings.SITH_SUBSCRIPTIONS[sub.subscription_type][\n \"duration\"\n ]\n )\n sub.subscription_end = Subscription.compute_end(\n duration=settings.SITH_SUBSCRIPTIONS[sub.subscription_type][\n \"duration\"\n ],\n start=sub.subscription_start,\n )\n sub.save()\n if user.preferences.notify_on_click:\n Notification(\n user=user,\n url=reverse(\n \"core:user_account_detail\",\n kwargs={\n \"user_id\": user.id,\n \"year\": self.date.year,\n \"month\": self.date.month,\n },\n ),\n param=\"%d x %s\" % (self.quantity, self.label),\n type=\"SELLING\",\n ).save()\n super().save(*args, **kwargs)\n if hasattr(self.product, \"eticket\"):\n self.send_mail_customer()\n
"},{"location":"reference/launderette/views/#launderette.views.Launderette","title":"Launderette
","text":" Bases: Model
is_owned_by(user)
","text":"Method to see if that object can be edited by the given user.
Source code inlaunderette/models.py
def is_owned_by(self, user):\n \"\"\"Method to see if that object can be edited by the given user.\"\"\"\n if user.is_anonymous:\n return False\n launderette_club = Club.objects.filter(\n unix_name=settings.SITH_LAUNDERETTE_MANAGER[\"unix_name\"]\n ).first()\n m = launderette_club.get_membership_for(user)\n return bool(m and m.role >= 9)\n
"},{"location":"reference/launderette/views/#launderette.views.Machine","title":"Machine
","text":" Bases: Model
is_owned_by(user)
","text":"Method to see if that object can be edited by the given user.
Source code inlaunderette/models.py
def is_owned_by(self, user):\n \"\"\"Method to see if that object can be edited by the given user.\"\"\"\n if user.is_anonymous:\n return False\n launderette_club = Club.objects.filter(\n unix_name=settings.SITH_LAUNDERETTE_MANAGER[\"unix_name\"]\n ).first()\n m = launderette_club.get_membership_for(user)\n return bool(m and m.role >= 9)\n
"},{"location":"reference/launderette/views/#launderette.views.Slot","title":"Slot
","text":" Bases: Model
Token
","text":" Bases: Model
is_owned_by(user)
","text":"Method to see if that object can be edited by the given user.
Source code inlaunderette/models.py
def is_owned_by(self, user):\n \"\"\"Method to see if that object can be edited by the given user.\"\"\"\n if user.is_anonymous:\n return False\n launderette_club = Club.objects.filter(\n unix_name=settings.SITH_LAUNDERETTE_MANAGER[\"unix_name\"]\n ).first()\n m = launderette_club.get_membership_for(user)\n return bool(m and m.role >= 9)\n
"},{"location":"reference/launderette/views/#launderette.views.LaunderetteMainView","title":"LaunderetteMainView
","text":" Bases: TemplateView
Main presentation view.
"},{"location":"reference/launderette/views/#launderette.views.LaunderetteMainView.get_context_data","title":"get_context_data(**kwargs)
","text":"Add page to the context.
Source code inlaunderette/views.py
def get_context_data(self, **kwargs):\n \"\"\"Add page to the context.\"\"\"\n kwargs = super().get_context_data(**kwargs)\n kwargs[\"page\"] = Page.objects.filter(name=\"launderette\").first()\n return kwargs\n
"},{"location":"reference/launderette/views/#launderette.views.LaunderetteBookMainView","title":"LaunderetteBookMainView
","text":" Bases: CanViewMixin
, ListView
Choose which launderette to book.
"},{"location":"reference/launderette/views/#launderette.views.LaunderetteBookView","title":"LaunderetteBookView
","text":" Bases: CanViewMixin
, DetailView
Display the launderette schedule.
"},{"location":"reference/launderette/views/#launderette.views.LaunderetteBookView.get_context_data","title":"get_context_data(**kwargs)
","text":"Add page to the context.
Source code inlaunderette/views.py
def get_context_data(self, **kwargs):\n \"\"\"Add page to the context.\"\"\"\n kwargs = super().get_context_data(**kwargs)\n kwargs[\"planning\"] = OrderedDict()\n kwargs[\"slot_type\"] = self.slot_type\n start_date = datetime.now().replace(\n hour=0, minute=0, second=0, microsecond=0, tzinfo=tz.utc\n )\n for date in LaunderetteBookView.date_iterator(\n start_date, start_date + timedelta(days=6), timedelta(days=1)\n ):\n kwargs[\"planning\"][date] = []\n for h in LaunderetteBookView.date_iterator(\n date, date + timedelta(days=1), timedelta(hours=1)\n ):\n free = False\n if (\n (\n self.slot_type == \"BOTH\"\n and self.check_slot(\"WASHING\", h)\n and self.check_slot(\"DRYING\", h + timedelta(hours=1))\n )\n or self.slot_type == \"WASHING\"\n and self.check_slot(\"WASHING\", h)\n or self.slot_type == \"DRYING\"\n and self.check_slot(\"DRYING\", h)\n ):\n free = True\n if free and datetime.now().replace(tzinfo=tz.utc) < h:\n kwargs[\"planning\"][date].append(h)\n else:\n kwargs[\"planning\"][date].append(None)\n return kwargs\n
"},{"location":"reference/launderette/views/#launderette.views.SlotDeleteView","title":"SlotDeleteView
","text":" Bases: CanEditPropMixin
, DeleteView
Delete a slot.
"},{"location":"reference/launderette/views/#launderette.views.LaunderetteListView","title":"LaunderetteListView
","text":" Bases: CanEditPropMixin
, ListView
Choose which launderette to administer.
"},{"location":"reference/launderette/views/#launderette.views.LaunderetteEditView","title":"LaunderetteEditView
","text":" Bases: CanEditPropMixin
, UpdateView
Edit a launderette.
"},{"location":"reference/launderette/views/#launderette.views.LaunderetteCreateView","title":"LaunderetteCreateView
","text":" Bases: CanCreateMixin
, CreateView
Create a new launderette.
"},{"location":"reference/launderette/views/#launderette.views.ManageTokenForm","title":"ManageTokenForm
","text":" Bases: Form
LaunderetteAdminView
","text":" Bases: CanEditPropMixin
, BaseFormView
, DetailView
The admin page of the launderette.
"},{"location":"reference/launderette/views/#launderette.views.LaunderetteAdminView.form_valid","title":"form_valid(form)
","text":"We handle here the redirection, passing the user id of the asked customer.
Source code inlaunderette/views.py
def form_valid(self, form):\n \"\"\"We handle here the redirection, passing the user id of the asked customer.\"\"\"\n form.process(self.object)\n if form.is_valid():\n return super().form_valid(form)\n else:\n return super().form_invalid(form)\n
"},{"location":"reference/launderette/views/#launderette.views.LaunderetteAdminView.get_context_data","title":"get_context_data(**kwargs)
","text":"We handle here the login form for the barman.
Source code inlaunderette/views.py
def get_context_data(self, **kwargs):\n \"\"\"We handle here the login form for the barman.\"\"\"\n kwargs = super().get_context_data(**kwargs)\n if self.request.method == \"GET\":\n kwargs[\"form\"] = self.get_form()\n return kwargs\n
"},{"location":"reference/launderette/views/#launderette.views.GetLaunderetteUserForm","title":"GetLaunderetteUserForm
","text":" Bases: GetUserForm
LaunderetteMainClickView
","text":" Bases: CanEditMixin
, BaseFormView
, DetailView
The click page of the launderette.
"},{"location":"reference/launderette/views/#launderette.views.LaunderetteMainClickView.form_valid","title":"form_valid(form)
","text":"We handle here the redirection, passing the user id of the asked customer.
Source code inlaunderette/views.py
def form_valid(self, form):\n \"\"\"We handle here the redirection, passing the user id of the asked customer.\"\"\"\n self.kwargs[\"user_id\"] = form.cleaned_data[\"user_id\"]\n return super().form_valid(form)\n
"},{"location":"reference/launderette/views/#launderette.views.LaunderetteMainClickView.get_context_data","title":"get_context_data(**kwargs)
","text":"We handle here the login form for the barman.
Source code inlaunderette/views.py
def get_context_data(self, **kwargs):\n \"\"\"We handle here the login form for the barman.\"\"\"\n kwargs = super().get_context_data(**kwargs)\n kwargs[\"counter\"] = self.object.counter\n kwargs[\"form\"] = self.get_form()\n kwargs[\"barmen\"] = [self.request.user]\n if \"last_basket\" in self.request.session:\n kwargs[\"last_basket\"] = self.request.session.pop(\"last_basket\", None)\n kwargs[\"last_customer\"] = self.request.session.pop(\"last_customer\", None)\n kwargs[\"last_total\"] = self.request.session.pop(\"last_total\", None)\n kwargs[\"new_customer_amount\"] = self.request.session.pop(\n \"new_customer_amount\", None\n )\n return kwargs\n
"},{"location":"reference/launderette/views/#launderette.views.ClickTokenForm","title":"ClickTokenForm
","text":" Bases: BaseForm
LaunderetteClickView
","text":" Bases: CanEditMixin
, DetailView
, BaseFormView
The click page of the launderette.
"},{"location":"reference/launderette/views/#launderette.views.LaunderetteClickView.get","title":"get(request, *args, **kwargs)
","text":"Simple get view.
Source code inlaunderette/views.py
def get(self, request, *args, **kwargs):\n \"\"\"Simple get view.\"\"\"\n self.customer = Customer.objects.filter(user__id=self.kwargs[\"user_id\"]).first()\n self.subscriber = self.customer.user\n self.operator = request.user\n return super().get(request, *args, **kwargs)\n
"},{"location":"reference/launderette/views/#launderette.views.LaunderetteClickView.post","title":"post(request, *args, **kwargs)
","text":"Handle the many possibilities of the post request.
Source code inlaunderette/views.py
def post(self, request, *args, **kwargs):\n \"\"\"Handle the many possibilities of the post request.\"\"\"\n self.object = self.get_object()\n self.customer = Customer.objects.filter(user__id=self.kwargs[\"user_id\"]).first()\n self.subscriber = self.customer.user\n self.operator = request.user\n return super().post(request, *args, **kwargs)\n
"},{"location":"reference/launderette/views/#launderette.views.LaunderetteClickView.form_valid","title":"form_valid(form)
","text":"We handle here the redirection, passing the user id of the asked customer.
Source code inlaunderette/views.py
def form_valid(self, form):\n \"\"\"We handle here the redirection, passing the user id of the asked customer.\"\"\"\n self.request.session.update(form.last_basket)\n return super().form_valid(form)\n
"},{"location":"reference/launderette/views/#launderette.views.LaunderetteClickView.get_context_data","title":"get_context_data(**kwargs)
","text":"We handle here the login form for the barman.
Source code inlaunderette/views.py
def get_context_data(self, **kwargs):\n \"\"\"We handle here the login form for the barman.\"\"\"\n kwargs = super().get_context_data(**kwargs)\n if \"form\" not in kwargs:\n kwargs[\"form\"] = self.get_form()\n kwargs[\"counter\"] = self.object.counter\n kwargs[\"customer\"] = self.customer\n return kwargs\n
"},{"location":"reference/launderette/views/#launderette.views.MachineEditView","title":"MachineEditView
","text":" Bases: CanEditPropMixin
, UpdateView
Edit a machine.
"},{"location":"reference/launderette/views/#launderette.views.MachineDeleteView","title":"MachineDeleteView
","text":" Bases: CanEditPropMixin
, DeleteView
Edit a machine.
"},{"location":"reference/launderette/views/#launderette.views.MachineCreateView","title":"MachineCreateView
","text":" Bases: CanCreateMixin
, CreateView
Create a new machine.
"},{"location":"reference/matmat/models/","title":"Models","text":""},{"location":"reference/matmat/views/","title":"Views","text":""},{"location":"reference/matmat/views/#matmat.views.User","title":"User
","text":" Bases: AbstractBaseUser
Defines the base user class, useable in every app.
This is almost the same as the auth module AbstractUser since it inherits from it, but some fields are required, and the username is generated automatically with the name of the user (see generate_username()).
Added field: nick_name, date_of_birth Required fields: email, first_name, last_name, date_of_birth
"},{"location":"reference/matmat/views/#matmat.views.User.cached_groups","title":"cached_groups: list[Group]
property
","text":"Get the list of groups this user is in.
The result is cached for the default duration (should be 5 minutes)
Returns: A list of all the groups this user is in.
"},{"location":"reference/matmat/views/#matmat.views.User.is_in_group","title":"is_in_group(*, pk=None, name=None)
","text":"Check if this user is in the given group. Either a group id or a group name must be provided. If both are passed, only the id will be considered.
The group will be fetched using the given parameter. If no group is found, return False. If a group is found, check if this user is in the latter.
Returns:
Type Descriptionbool
True if the user is the group, else False
Source code incore/models.py
def is_in_group(self, *, pk: int | None = None, name: str | None = None) -> bool:\n \"\"\"Check if this user is in the given group.\n Either a group id or a group name must be provided.\n If both are passed, only the id will be considered.\n\n The group will be fetched using the given parameter.\n If no group is found, return False.\n If a group is found, check if this user is in the latter.\n\n Returns:\n True if the user is the group, else False\n \"\"\"\n if pk is not None:\n group: Optional[Group] = get_group(pk=pk)\n elif name is not None:\n group: Optional[Group] = get_group(name=name)\n else:\n raise ValueError(\"You must either provide the id or the name of the group\")\n if group is None:\n return False\n if group.id == settings.SITH_GROUP_PUBLIC_ID:\n return True\n if group.id == settings.SITH_GROUP_SUBSCRIBERS_ID:\n return self.is_subscribed\n if group.id == settings.SITH_GROUP_OLD_SUBSCRIBERS_ID:\n return self.was_subscribed\n if group.id == settings.SITH_GROUP_ROOT_ID:\n return self.is_root\n if group.is_meta:\n # check if this group is associated with a club\n group.__class__ = MetaGroup\n club = group.associated_club\n if club is None:\n return False\n membership = club.get_membership_for(self)\n if membership is None:\n return False\n if group.name.endswith(settings.SITH_MEMBER_SUFFIX):\n return True\n return membership.role > settings.SITH_MAXIMUM_FREE_ROLE\n return group in self.cached_groups\n
"},{"location":"reference/matmat/views/#matmat.views.User.age","title":"age()
","text":"Return the age this user has the day the method is called. If the user has not filled his age, return 0.
Source code incore/models.py
@cached_property\ndef age(self) -> int:\n \"\"\"Return the age this user has the day the method is called.\n If the user has not filled his age, return 0.\n \"\"\"\n if self.date_of_birth is None:\n return 0\n today = timezone.now()\n age = today.year - self.date_of_birth.year\n # remove a year if this year's birthday is yet to come\n age -= (today.month, today.day) < (\n self.date_of_birth.month,\n self.date_of_birth.day,\n )\n return age\n
"},{"location":"reference/matmat/views/#matmat.views.User.get_full_name","title":"get_full_name()
","text":"Returns the first_name plus the last_name, with a space in between.
Source code incore/models.py
def get_full_name(self):\n \"\"\"Returns the first_name plus the last_name, with a space in between.\"\"\"\n full_name = \"%s %s\" % (self.first_name, self.last_name)\n return full_name.strip()\n
"},{"location":"reference/matmat/views/#matmat.views.User.get_short_name","title":"get_short_name()
","text":"Returns the short name for the user.
Source code incore/models.py
def get_short_name(self):\n \"\"\"Returns the short name for the user.\"\"\"\n if self.nick_name:\n return self.nick_name\n return self.first_name + \" \" + self.last_name\n
"},{"location":"reference/matmat/views/#matmat.views.User.get_display_name","title":"get_display_name()
","text":"Returns the display name of the user.
A nickname if possible, otherwise, the full name.
Source code incore/models.py
def get_display_name(self) -> str:\n \"\"\"Returns the display name of the user.\n\n A nickname if possible, otherwise, the full name.\n \"\"\"\n if self.nick_name:\n return \"%s (%s)\" % (self.get_full_name(), self.nick_name)\n return self.get_full_name()\n
"},{"location":"reference/matmat/views/#matmat.views.User.get_age","title":"get_age()
","text":"Returns the age.
Source code incore/models.py
def get_age(self):\n \"\"\"Returns the age.\"\"\"\n today = timezone.now()\n born = self.date_of_birth\n return (\n today.year - born.year - ((today.month, today.day) < (born.month, born.day))\n )\n
"},{"location":"reference/matmat/views/#matmat.views.User.get_family","title":"get_family(godfathers_depth=4, godchildren_depth=4)
","text":"Get the family of the user, with the given depth.
Parameters:
Name Type Description Defaultgodfathers_depth
NonNegativeInt
The number of generations of godfathers to fetch
4
godchildren_depth
NonNegativeInt
The number of generations of godchildren to fetch
4
Returns:
Type Descriptionset[through]
A list of family relationships in this user's family
Source code incore/models.py
def get_family(\n self,\n godfathers_depth: NonNegativeInt = 4,\n godchildren_depth: NonNegativeInt = 4,\n) -> set[User.godfathers.through]:\n \"\"\"Get the family of the user, with the given depth.\n\n Args:\n godfathers_depth: The number of generations of godfathers to fetch\n godchildren_depth: The number of generations of godchildren to fetch\n\n Returns:\n A list of family relationships in this user's family\n \"\"\"\n res = []\n for depth, key, reverse_key in [\n (godfathers_depth, \"from_user_id\", \"to_user_id\"),\n (godchildren_depth, \"to_user_id\", \"from_user_id\"),\n ]:\n if depth == 0:\n continue\n links = list(User.godfathers.through.objects.filter(**{key: self.id}))\n res.extend(links)\n for _ in range(1, depth): # noqa: F402 we don't care about gettext here\n ids = [getattr(c, reverse_key) for c in links]\n links = list(\n User.godfathers.through.objects.filter(\n **{f\"{key}__in\": ids}\n ).exclude(id__in=[r.id for r in res])\n )\n if not links:\n break\n res.extend(links)\n return set(res)\n
"},{"location":"reference/matmat/views/#matmat.views.User.email_user","title":"email_user(subject, message, from_email=None, **kwargs)
","text":"Sends an email to this User.
Source code incore/models.py
def email_user(self, subject, message, from_email=None, **kwargs):\n \"\"\"Sends an email to this User.\"\"\"\n if from_email is None:\n from_email = settings.DEFAULT_FROM_EMAIL\n send_mail(subject, message, from_email, [self.email], **kwargs)\n
"},{"location":"reference/matmat/views/#matmat.views.User.generate_username","title":"generate_username()
","text":"Generates a unique username based on the first and last names.
For example: Guy Carlier gives gcarlier, and gcarlier1 if the first one exists.
Returns:
Type Descriptionstr
The generated username.
Source code incore/models.py
def generate_username(self) -> str:\n \"\"\"Generates a unique username based on the first and last names.\n\n For example: Guy Carlier gives gcarlier, and gcarlier1 if the first one exists.\n\n Returns:\n The generated username.\n \"\"\"\n\n def remove_accents(data):\n return \"\".join(\n x\n for x in unicodedata.normalize(\"NFKD\", data)\n if unicodedata.category(x)[0] == \"L\"\n ).lower()\n\n user_name = (\n remove_accents(self.first_name[0] + self.last_name)\n .encode(\"ascii\", \"ignore\")\n .decode(\"utf-8\")\n )\n un_set = [u.username for u in User.objects.all()]\n if user_name in un_set:\n i = 1\n while user_name + str(i) in un_set:\n i += 1\n user_name += str(i)\n self.username = user_name\n return user_name\n
"},{"location":"reference/matmat/views/#matmat.views.User.is_owner","title":"is_owner(obj)
","text":"Determine if the object is owned by the user.
Source code incore/models.py
def is_owner(self, obj):\n \"\"\"Determine if the object is owned by the user.\"\"\"\n if hasattr(obj, \"is_owned_by\") and obj.is_owned_by(self):\n return True\n if hasattr(obj, \"owner_group\") and self.is_in_group(pk=obj.owner_group.id):\n return True\n return self.is_root\n
"},{"location":"reference/matmat/views/#matmat.views.User.can_edit","title":"can_edit(obj)
","text":"Determine if the object can be edited by the user.
Source code incore/models.py
def can_edit(self, obj):\n \"\"\"Determine if the object can be edited by the user.\"\"\"\n if hasattr(obj, \"can_be_edited_by\") and obj.can_be_edited_by(self):\n return True\n if hasattr(obj, \"edit_groups\"):\n for pk in obj.edit_groups.values_list(\"pk\", flat=True):\n if self.is_in_group(pk=pk):\n return True\n if isinstance(obj, User) and obj == self:\n return True\n return self.is_owner(obj)\n
"},{"location":"reference/matmat/views/#matmat.views.User.can_view","title":"can_view(obj)
","text":"Determine if the object can be viewed by the user.
Source code incore/models.py
def can_view(self, obj):\n \"\"\"Determine if the object can be viewed by the user.\"\"\"\n if hasattr(obj, \"can_be_viewed_by\") and obj.can_be_viewed_by(self):\n return True\n if hasattr(obj, \"view_groups\"):\n for pk in obj.view_groups.values_list(\"pk\", flat=True):\n if self.is_in_group(pk=pk):\n return True\n return self.can_edit(obj)\n
"},{"location":"reference/matmat/views/#matmat.views.User.clubs_with_rights","title":"clubs_with_rights()
","text":"The list of clubs where the user has rights
Source code incore/models.py
@cached_property\ndef clubs_with_rights(self) -> list[Club]:\n \"\"\"The list of clubs where the user has rights\"\"\"\n memberships = self.memberships.ongoing().board().select_related(\"club\")\n return [m.club for m in memberships]\n
"},{"location":"reference/matmat/views/#matmat.views.FormerSubscriberMixin","title":"FormerSubscriberMixin
","text":" Bases: AccessMixin
Check if the user was at least an old subscriber.
Raises:
Type DescriptionPermissionDenied
if the user never subscribed.
"},{"location":"reference/matmat/views/#matmat.views.SearchType","title":"SearchType
","text":" Bases: Enum
SearchForm(*args, **kwargs)
","text":" Bases: ModelForm
matmat/views.py
def __init__(self, *args, **kwargs):\n super().__init__(*args, **kwargs)\n for key in self.fields:\n self.fields[key].required = False\n
"},{"location":"reference/matmat/views/#matmat.views.SearchFormListView","title":"SearchFormListView
","text":" Bases: FormerSubscriberMixin
, SingleObjectMixin
, ListView
SearchFormView
","text":" Bases: FormerSubscriberMixin
, FormView
Allows users to search inside the user list.
"},{"location":"reference/matmat/views/#matmat.views.SearchNormalFormView","title":"SearchNormalFormView
","text":" Bases: SearchFormView
SearchReverseFormView
","text":" Bases: SearchFormView
SearchQuickFormView
","text":" Bases: SearchFormView
SearchClearFormView
","text":" Bases: FormerSubscriberMixin
, View
Clear SearchFormView and redirect to it.
"},{"location":"reference/matmat/views/#matmat.views.search_user","title":"search_user(query)
","text":"Source code in core/views/site.py
def search_user(query):\n try:\n # slugify turns everything into ascii and every whitespace into -\n # it ends by removing duplicate - (so ' - ' will turn into '-')\n # replace('-', ' ') because search is whitespace based\n query = slugify(query).replace(\"-\", \" \")\n # TODO: is this necessary?\n query = html.escape(query)\n res = (\n SearchQuerySet()\n .models(User)\n .autocomplete(auto=query)\n .order_by(\"-last_login\")\n .load_all()[:20]\n )\n return [r.object for r in res]\n except TypeError:\n return []\n
"},{"location":"reference/pedagogy/models/","title":"Models","text":""},{"location":"reference/pedagogy/models/#pedagogy.models.User","title":"User
","text":" Bases: AbstractBaseUser
Defines the base user class, useable in every app.
This is almost the same as the auth module AbstractUser since it inherits from it, but some fields are required, and the username is generated automatically with the name of the user (see generate_username()).
Added field: nick_name, date_of_birth Required fields: email, first_name, last_name, date_of_birth
"},{"location":"reference/pedagogy/models/#pedagogy.models.User.cached_groups","title":"cached_groups: list[Group]
property
","text":"Get the list of groups this user is in.
The result is cached for the default duration (should be 5 minutes)
Returns: A list of all the groups this user is in.
"},{"location":"reference/pedagogy/models/#pedagogy.models.User.is_in_group","title":"is_in_group(*, pk=None, name=None)
","text":"Check if this user is in the given group. Either a group id or a group name must be provided. If both are passed, only the id will be considered.
The group will be fetched using the given parameter. If no group is found, return False. If a group is found, check if this user is in the latter.
Returns:
Type Descriptionbool
True if the user is the group, else False
Source code incore/models.py
def is_in_group(self, *, pk: int | None = None, name: str | None = None) -> bool:\n \"\"\"Check if this user is in the given group.\n Either a group id or a group name must be provided.\n If both are passed, only the id will be considered.\n\n The group will be fetched using the given parameter.\n If no group is found, return False.\n If a group is found, check if this user is in the latter.\n\n Returns:\n True if the user is the group, else False\n \"\"\"\n if pk is not None:\n group: Optional[Group] = get_group(pk=pk)\n elif name is not None:\n group: Optional[Group] = get_group(name=name)\n else:\n raise ValueError(\"You must either provide the id or the name of the group\")\n if group is None:\n return False\n if group.id == settings.SITH_GROUP_PUBLIC_ID:\n return True\n if group.id == settings.SITH_GROUP_SUBSCRIBERS_ID:\n return self.is_subscribed\n if group.id == settings.SITH_GROUP_OLD_SUBSCRIBERS_ID:\n return self.was_subscribed\n if group.id == settings.SITH_GROUP_ROOT_ID:\n return self.is_root\n if group.is_meta:\n # check if this group is associated with a club\n group.__class__ = MetaGroup\n club = group.associated_club\n if club is None:\n return False\n membership = club.get_membership_for(self)\n if membership is None:\n return False\n if group.name.endswith(settings.SITH_MEMBER_SUFFIX):\n return True\n return membership.role > settings.SITH_MAXIMUM_FREE_ROLE\n return group in self.cached_groups\n
"},{"location":"reference/pedagogy/models/#pedagogy.models.User.age","title":"age()
","text":"Return the age this user has the day the method is called. If the user has not filled his age, return 0.
Source code incore/models.py
@cached_property\ndef age(self) -> int:\n \"\"\"Return the age this user has the day the method is called.\n If the user has not filled his age, return 0.\n \"\"\"\n if self.date_of_birth is None:\n return 0\n today = timezone.now()\n age = today.year - self.date_of_birth.year\n # remove a year if this year's birthday is yet to come\n age -= (today.month, today.day) < (\n self.date_of_birth.month,\n self.date_of_birth.day,\n )\n return age\n
"},{"location":"reference/pedagogy/models/#pedagogy.models.User.get_full_name","title":"get_full_name()
","text":"Returns the first_name plus the last_name, with a space in between.
Source code incore/models.py
def get_full_name(self):\n \"\"\"Returns the first_name plus the last_name, with a space in between.\"\"\"\n full_name = \"%s %s\" % (self.first_name, self.last_name)\n return full_name.strip()\n
"},{"location":"reference/pedagogy/models/#pedagogy.models.User.get_short_name","title":"get_short_name()
","text":"Returns the short name for the user.
Source code incore/models.py
def get_short_name(self):\n \"\"\"Returns the short name for the user.\"\"\"\n if self.nick_name:\n return self.nick_name\n return self.first_name + \" \" + self.last_name\n
"},{"location":"reference/pedagogy/models/#pedagogy.models.User.get_display_name","title":"get_display_name()
","text":"Returns the display name of the user.
A nickname if possible, otherwise, the full name.
Source code incore/models.py
def get_display_name(self) -> str:\n \"\"\"Returns the display name of the user.\n\n A nickname if possible, otherwise, the full name.\n \"\"\"\n if self.nick_name:\n return \"%s (%s)\" % (self.get_full_name(), self.nick_name)\n return self.get_full_name()\n
"},{"location":"reference/pedagogy/models/#pedagogy.models.User.get_age","title":"get_age()
","text":"Returns the age.
Source code incore/models.py
def get_age(self):\n \"\"\"Returns the age.\"\"\"\n today = timezone.now()\n born = self.date_of_birth\n return (\n today.year - born.year - ((today.month, today.day) < (born.month, born.day))\n )\n
"},{"location":"reference/pedagogy/models/#pedagogy.models.User.get_family","title":"get_family(godfathers_depth=4, godchildren_depth=4)
","text":"Get the family of the user, with the given depth.
Parameters:
Name Type Description Defaultgodfathers_depth
NonNegativeInt
The number of generations of godfathers to fetch
4
godchildren_depth
NonNegativeInt
The number of generations of godchildren to fetch
4
Returns:
Type Descriptionset[through]
A list of family relationships in this user's family
Source code incore/models.py
def get_family(\n self,\n godfathers_depth: NonNegativeInt = 4,\n godchildren_depth: NonNegativeInt = 4,\n) -> set[User.godfathers.through]:\n \"\"\"Get the family of the user, with the given depth.\n\n Args:\n godfathers_depth: The number of generations of godfathers to fetch\n godchildren_depth: The number of generations of godchildren to fetch\n\n Returns:\n A list of family relationships in this user's family\n \"\"\"\n res = []\n for depth, key, reverse_key in [\n (godfathers_depth, \"from_user_id\", \"to_user_id\"),\n (godchildren_depth, \"to_user_id\", \"from_user_id\"),\n ]:\n if depth == 0:\n continue\n links = list(User.godfathers.through.objects.filter(**{key: self.id}))\n res.extend(links)\n for _ in range(1, depth): # noqa: F402 we don't care about gettext here\n ids = [getattr(c, reverse_key) for c in links]\n links = list(\n User.godfathers.through.objects.filter(\n **{f\"{key}__in\": ids}\n ).exclude(id__in=[r.id for r in res])\n )\n if not links:\n break\n res.extend(links)\n return set(res)\n
"},{"location":"reference/pedagogy/models/#pedagogy.models.User.email_user","title":"email_user(subject, message, from_email=None, **kwargs)
","text":"Sends an email to this User.
Source code incore/models.py
def email_user(self, subject, message, from_email=None, **kwargs):\n \"\"\"Sends an email to this User.\"\"\"\n if from_email is None:\n from_email = settings.DEFAULT_FROM_EMAIL\n send_mail(subject, message, from_email, [self.email], **kwargs)\n
"},{"location":"reference/pedagogy/models/#pedagogy.models.User.generate_username","title":"generate_username()
","text":"Generates a unique username based on the first and last names.
For example: Guy Carlier gives gcarlier, and gcarlier1 if the first one exists.
Returns:
Type Descriptionstr
The generated username.
Source code incore/models.py
def generate_username(self) -> str:\n \"\"\"Generates a unique username based on the first and last names.\n\n For example: Guy Carlier gives gcarlier, and gcarlier1 if the first one exists.\n\n Returns:\n The generated username.\n \"\"\"\n\n def remove_accents(data):\n return \"\".join(\n x\n for x in unicodedata.normalize(\"NFKD\", data)\n if unicodedata.category(x)[0] == \"L\"\n ).lower()\n\n user_name = (\n remove_accents(self.first_name[0] + self.last_name)\n .encode(\"ascii\", \"ignore\")\n .decode(\"utf-8\")\n )\n un_set = [u.username for u in User.objects.all()]\n if user_name in un_set:\n i = 1\n while user_name + str(i) in un_set:\n i += 1\n user_name += str(i)\n self.username = user_name\n return user_name\n
"},{"location":"reference/pedagogy/models/#pedagogy.models.User.is_owner","title":"is_owner(obj)
","text":"Determine if the object is owned by the user.
Source code incore/models.py
def is_owner(self, obj):\n \"\"\"Determine if the object is owned by the user.\"\"\"\n if hasattr(obj, \"is_owned_by\") and obj.is_owned_by(self):\n return True\n if hasattr(obj, \"owner_group\") and self.is_in_group(pk=obj.owner_group.id):\n return True\n return self.is_root\n
"},{"location":"reference/pedagogy/models/#pedagogy.models.User.can_edit","title":"can_edit(obj)
","text":"Determine if the object can be edited by the user.
Source code incore/models.py
def can_edit(self, obj):\n \"\"\"Determine if the object can be edited by the user.\"\"\"\n if hasattr(obj, \"can_be_edited_by\") and obj.can_be_edited_by(self):\n return True\n if hasattr(obj, \"edit_groups\"):\n for pk in obj.edit_groups.values_list(\"pk\", flat=True):\n if self.is_in_group(pk=pk):\n return True\n if isinstance(obj, User) and obj == self:\n return True\n return self.is_owner(obj)\n
"},{"location":"reference/pedagogy/models/#pedagogy.models.User.can_view","title":"can_view(obj)
","text":"Determine if the object can be viewed by the user.
Source code incore/models.py
def can_view(self, obj):\n \"\"\"Determine if the object can be viewed by the user.\"\"\"\n if hasattr(obj, \"can_be_viewed_by\") and obj.can_be_viewed_by(self):\n return True\n if hasattr(obj, \"view_groups\"):\n for pk in obj.view_groups.values_list(\"pk\", flat=True):\n if self.is_in_group(pk=pk):\n return True\n return self.can_edit(obj)\n
"},{"location":"reference/pedagogy/models/#pedagogy.models.User.clubs_with_rights","title":"clubs_with_rights()
","text":"The list of clubs where the user has rights
Source code incore/models.py
@cached_property\ndef clubs_with_rights(self) -> list[Club]:\n \"\"\"The list of clubs where the user has rights\"\"\"\n memberships = self.memberships.ongoing().board().select_related(\"club\")\n return [m.club for m in memberships]\n
"},{"location":"reference/pedagogy/models/#pedagogy.models.UV","title":"UV
","text":" Bases: Model
Contains infos about an UV (course).
"},{"location":"reference/pedagogy/models/#pedagogy.models.UV.is_owned_by","title":"is_owned_by(user)
","text":"Can be created by superuser, root or pedagogy admin user.
Source code inpedagogy/models.py
def is_owned_by(self, user):\n \"\"\"Can be created by superuser, root or pedagogy admin user.\"\"\"\n return user.is_in_group(pk=settings.SITH_GROUP_PEDAGOGY_ADMIN_ID)\n
"},{"location":"reference/pedagogy/models/#pedagogy.models.UV.can_be_viewed_by","title":"can_be_viewed_by(user)
","text":"Only visible by subscribers.
Source code inpedagogy/models.py
def can_be_viewed_by(self, user):\n \"\"\"Only visible by subscribers.\"\"\"\n return user.is_subscribed\n
"},{"location":"reference/pedagogy/models/#pedagogy.models.UV.has_user_already_commented","title":"has_user_already_commented(user)
","text":"Help prevent multiples comments from the same user.
This function checks that no other comment has been posted by a specified user.
Returns:
Type Descriptionbool
True if the user has already posted a comment on this UV, else False.
Source code inpedagogy/models.py
def has_user_already_commented(self, user: User) -> bool:\n \"\"\"Help prevent multiples comments from the same user.\n\n This function checks that no other comment has been posted by a specified user.\n\n Returns:\n True if the user has already posted a comment on this UV, else False.\n \"\"\"\n return self.comments.filter(author=user).exists()\n
"},{"location":"reference/pedagogy/models/#pedagogy.models.UVComment","title":"UVComment
","text":" Bases: Model
A comment about an UV.
"},{"location":"reference/pedagogy/models/#pedagogy.models.UVComment.is_owned_by","title":"is_owned_by(user)
","text":"Is owned by a pedagogy admin, a superuser or the author himself.
Source code inpedagogy/models.py
def is_owned_by(self, user):\n \"\"\"Is owned by a pedagogy admin, a superuser or the author himself.\"\"\"\n return self.author == user or user.is_owner(self.uv)\n
"},{"location":"reference/pedagogy/models/#pedagogy.models.UVComment.is_reported","title":"is_reported()
","text":"Return True if someone reported this UV.
Source code inpedagogy/models.py
@cached_property\ndef is_reported(self):\n \"\"\"Return True if someone reported this UV.\"\"\"\n return self.reports.exists()\n
"},{"location":"reference/pedagogy/models/#pedagogy.models.UVResult","title":"UVResult
","text":" Bases: Model
Results got to an UV.
Views will be implemented after the first release Will list every UV done by an user Linked to user uv Contains a grade settings.SITH_PEDAGOGY_UV_RESULT_GRADE a semester (P/A)20xx.
"},{"location":"reference/pedagogy/models/#pedagogy.models.UVCommentReport","title":"UVCommentReport
","text":" Bases: Model
Report an inapropriate comment.
"},{"location":"reference/pedagogy/models/#pedagogy.models.UVCommentReport.is_owned_by","title":"is_owned_by(user)
","text":"Can be created by a pedagogy admin, a superuser or a subscriber.
Source code inpedagogy/models.py
def is_owned_by(self, user):\n \"\"\"Can be created by a pedagogy admin, a superuser or a subscriber.\"\"\"\n return user.is_subscribed or user.is_owner(self.comment.uv)\n
"},{"location":"reference/pedagogy/schemas/","title":"Schemas","text":""},{"location":"reference/pedagogy/schemas/#pedagogy.schemas.ShortUvList","title":"ShortUvList = TypeAdapter(list[UtbmShortUvSchema])
module-attribute
","text":""},{"location":"reference/pedagogy/schemas/#pedagogy.schemas.UV","title":"UV
","text":" Bases: Model
Contains infos about an UV (course).
"},{"location":"reference/pedagogy/schemas/#pedagogy.schemas.UV.is_owned_by","title":"is_owned_by(user)
","text":"Can be created by superuser, root or pedagogy admin user.
Source code inpedagogy/models.py
def is_owned_by(self, user):\n \"\"\"Can be created by superuser, root or pedagogy admin user.\"\"\"\n return user.is_in_group(pk=settings.SITH_GROUP_PEDAGOGY_ADMIN_ID)\n
"},{"location":"reference/pedagogy/schemas/#pedagogy.schemas.UV.can_be_viewed_by","title":"can_be_viewed_by(user)
","text":"Only visible by subscribers.
Source code inpedagogy/models.py
def can_be_viewed_by(self, user):\n \"\"\"Only visible by subscribers.\"\"\"\n return user.is_subscribed\n
"},{"location":"reference/pedagogy/schemas/#pedagogy.schemas.UV.has_user_already_commented","title":"has_user_already_commented(user)
","text":"Help prevent multiples comments from the same user.
This function checks that no other comment has been posted by a specified user.
Returns:
Type Descriptionbool
True if the user has already posted a comment on this UV, else False.
Source code inpedagogy/models.py
def has_user_already_commented(self, user: User) -> bool:\n \"\"\"Help prevent multiples comments from the same user.\n\n This function checks that no other comment has been posted by a specified user.\n\n Returns:\n True if the user has already posted a comment on this UV, else False.\n \"\"\"\n return self.comments.filter(author=user).exists()\n
"},{"location":"reference/pedagogy/schemas/#pedagogy.schemas.UtbmShortUvSchema","title":"UtbmShortUvSchema
","text":" Bases: Schema
Short representation of an UV in the UTBM API.
NotesThis schema holds only the fields we actually need. The UTBM API returns more data than that.
"},{"location":"reference/pedagogy/schemas/#pedagogy.schemas.WorkloadSchema","title":"WorkloadSchema
","text":" Bases: Schema
SemesterUvState
","text":" Bases: Schema
The state of the UV during either autumn or spring semester
"},{"location":"reference/pedagogy/schemas/#pedagogy.schemas.UtbmFullUvSchema","title":"UtbmFullUvSchema
","text":" Bases: Schema
Long representation of an UV in the UTBM API.
"},{"location":"reference/pedagogy/schemas/#pedagogy.schemas.SimpleUvSchema","title":"SimpleUvSchema
","text":" Bases: ModelSchema
Our minimal representation of an UV.
"},{"location":"reference/pedagogy/schemas/#pedagogy.schemas.UvSchema","title":"UvSchema
","text":" Bases: ModelSchema
Our complete representation of an UV
"},{"location":"reference/pedagogy/schemas/#pedagogy.schemas.UvFilterSchema","title":"UvFilterSchema
","text":" Bases: FilterSchema
filter_search(value)
","text":"Special filter for the search text.
It does a full text search if available.
Source code inpedagogy/schemas.py
def filter_search(self, value: str | None) -> Q:\n \"\"\"Special filter for the search text.\n\n It does a full text search if available.\n \"\"\"\n if not value:\n return Q()\n\n if len(value) < 3 or (len(value) < 5 and any(c.isdigit() for c in value)):\n # Likely to be an UV code\n return Q(code__istartswith=value)\n\n qs = list(\n SearchQuerySet()\n .models(UV)\n .autocomplete(auto=html.escape(value))\n .values_list(\"pk\", flat=True)\n )\n\n return Q(id__in=qs)\n
"},{"location":"reference/pedagogy/schemas/#pedagogy.schemas.UvFilterSchema.filter_semester","title":"filter_semester(value)
","text":"Special filter for the semester.
If either \"SPRING\" or \"AUTUMN\" is given, UV that are available during \"AUTUMN_AND_SPRING\" will be filtered.
Source code inpedagogy/schemas.py
def filter_semester(self, value: set[str] | None) -> Q:\n \"\"\"Special filter for the semester.\n\n If either \"SPRING\" or \"AUTUMN\" is given, UV that are available\n during \"AUTUMN_AND_SPRING\" will be filtered.\n \"\"\"\n if not value:\n return Q()\n value.add(\"AUTUMN_AND_SPRING\")\n return Q(semester__in=value)\n
"},{"location":"reference/pedagogy/views/","title":"Views","text":""},{"location":"reference/pedagogy/views/#pedagogy.views.Notification","title":"Notification
","text":" Bases: Model
RealGroup
","text":" Bases: Group
RealGroups are created by the developer.
Most of the time they match a number in settings to be easily used for permissions.
"},{"location":"reference/pedagogy/views/#pedagogy.views.CanCreateMixin","title":"CanCreateMixin
","text":" Bases: View
Protect any child view that would create an object.
Raises:
Type DescriptionPermissionDenied
If the user has not the necessary permission to create the object of the view.
"},{"location":"reference/pedagogy/views/#pedagogy.views.CanEditPropMixin","title":"CanEditPropMixin
","text":" Bases: GenericContentPermissionMixinBuilder
Ensure the user has owner permissions on the child view object.
In other word, you can make a view with this view as parent, and it will be retricted to the users that are in the object's owner_group or that pass the obj.can_be_viewed_by
test.
Raises:
Type DescriptionPermissionDenied
If the user cannot see the object
"},{"location":"reference/pedagogy/views/#pedagogy.views.CanViewMixin","title":"CanViewMixin
","text":" Bases: GenericContentPermissionMixinBuilder
Ensure the user has permission to view this view's object.
Raises:
Type DescriptionPermissionDenied
if the user cannot edit this view's object.
"},{"location":"reference/pedagogy/views/#pedagogy.views.DetailFormView","title":"DetailFormView
","text":" Bases: SingleObjectMixin
, FormView
Class that allow both a detail view and a form view.
"},{"location":"reference/pedagogy/views/#pedagogy.views.DetailFormView.get_object","title":"get_object()
","text":"Get current group from id in url.
Source code incore/views/__init__.py
def get_object(self):\n \"\"\"Get current group from id in url.\"\"\"\n return self.cached_object\n
"},{"location":"reference/pedagogy/views/#pedagogy.views.DetailFormView.cached_object","title":"cached_object()
","text":"Optimisation on group retrieval.
Source code incore/views/__init__.py
@cached_property\ndef cached_object(self):\n \"\"\"Optimisation on group retrieval.\"\"\"\n return super().get_object()\n
"},{"location":"reference/pedagogy/views/#pedagogy.views.FormerSubscriberMixin","title":"FormerSubscriberMixin
","text":" Bases: AccessMixin
Check if the user was at least an old subscriber.
Raises:
Type DescriptionPermissionDenied
if the user never subscribed.
"},{"location":"reference/pedagogy/views/#pedagogy.views.UVCommentForm","title":"UVCommentForm(author_id, uv_id, is_creation, *args, **kwargs)
","text":" Bases: ModelForm
Form handeling creation and edit of an UVComment.
Source code inpedagogy/forms.py
def __init__(self, author_id, uv_id, is_creation, *args, **kwargs):\n super().__init__(*args, **kwargs)\n self.fields[\"author\"].queryset = User.objects.filter(id=author_id).all()\n self.fields[\"author\"].initial = author_id\n self.fields[\"uv\"].queryset = UV.objects.filter(id=uv_id).all()\n self.fields[\"uv\"].initial = uv_id\n self.is_creation = is_creation\n
"},{"location":"reference/pedagogy/views/#pedagogy.views.UVCommentModerationForm","title":"UVCommentModerationForm
","text":" Bases: Form
Form handeling bulk comment deletion.
"},{"location":"reference/pedagogy/views/#pedagogy.views.UVCommentReportForm","title":"UVCommentReportForm(reporter_id, comment_id, *args, **kwargs)
","text":" Bases: ModelForm
Form handeling creation and edit of an UVReport.
Source code inpedagogy/forms.py
def __init__(self, reporter_id, comment_id, *args, **kwargs):\n super().__init__(*args, **kwargs)\n self.fields[\"reporter\"].queryset = User.objects.filter(id=reporter_id).all()\n self.fields[\"reporter\"].initial = reporter_id\n self.fields[\"comment\"].queryset = UVComment.objects.filter(id=comment_id).all()\n self.fields[\"comment\"].initial = comment_id\n
"},{"location":"reference/pedagogy/views/#pedagogy.views.UVForm","title":"UVForm(author_id, *args, **kwargs)
","text":" Bases: ModelForm
Form handeling creation and edit of an UV.
Source code inpedagogy/forms.py
def __init__(self, author_id, *args, **kwargs):\n super().__init__(*args, **kwargs)\n self.fields[\"author\"].queryset = User.objects.filter(id=author_id).all()\n self.fields[\"author\"].initial = author_id\n
"},{"location":"reference/pedagogy/views/#pedagogy.views.UV","title":"UV
","text":" Bases: Model
Contains infos about an UV (course).
"},{"location":"reference/pedagogy/views/#pedagogy.views.UV.is_owned_by","title":"is_owned_by(user)
","text":"Can be created by superuser, root or pedagogy admin user.
Source code inpedagogy/models.py
def is_owned_by(self, user):\n \"\"\"Can be created by superuser, root or pedagogy admin user.\"\"\"\n return user.is_in_group(pk=settings.SITH_GROUP_PEDAGOGY_ADMIN_ID)\n
"},{"location":"reference/pedagogy/views/#pedagogy.views.UV.can_be_viewed_by","title":"can_be_viewed_by(user)
","text":"Only visible by subscribers.
Source code inpedagogy/models.py
def can_be_viewed_by(self, user):\n \"\"\"Only visible by subscribers.\"\"\"\n return user.is_subscribed\n
"},{"location":"reference/pedagogy/views/#pedagogy.views.UV.has_user_already_commented","title":"has_user_already_commented(user)
","text":"Help prevent multiples comments from the same user.
This function checks that no other comment has been posted by a specified user.
Returns:
Type Descriptionbool
True if the user has already posted a comment on this UV, else False.
Source code inpedagogy/models.py
def has_user_already_commented(self, user: User) -> bool:\n \"\"\"Help prevent multiples comments from the same user.\n\n This function checks that no other comment has been posted by a specified user.\n\n Returns:\n True if the user has already posted a comment on this UV, else False.\n \"\"\"\n return self.comments.filter(author=user).exists()\n
"},{"location":"reference/pedagogy/views/#pedagogy.views.UVComment","title":"UVComment
","text":" Bases: Model
A comment about an UV.
"},{"location":"reference/pedagogy/views/#pedagogy.views.UVComment.is_owned_by","title":"is_owned_by(user)
","text":"Is owned by a pedagogy admin, a superuser or the author himself.
Source code inpedagogy/models.py
def is_owned_by(self, user):\n \"\"\"Is owned by a pedagogy admin, a superuser or the author himself.\"\"\"\n return self.author == user or user.is_owner(self.uv)\n
"},{"location":"reference/pedagogy/views/#pedagogy.views.UVComment.is_reported","title":"is_reported()
","text":"Return True if someone reported this UV.
Source code inpedagogy/models.py
@cached_property\ndef is_reported(self):\n \"\"\"Return True if someone reported this UV.\"\"\"\n return self.reports.exists()\n
"},{"location":"reference/pedagogy/views/#pedagogy.views.UVCommentReport","title":"UVCommentReport
","text":" Bases: Model
Report an inapropriate comment.
"},{"location":"reference/pedagogy/views/#pedagogy.views.UVCommentReport.is_owned_by","title":"is_owned_by(user)
","text":"Can be created by a pedagogy admin, a superuser or a subscriber.
Source code inpedagogy/models.py
def is_owned_by(self, user):\n \"\"\"Can be created by a pedagogy admin, a superuser or a subscriber.\"\"\"\n return user.is_subscribed or user.is_owner(self.comment.uv)\n
"},{"location":"reference/pedagogy/views/#pedagogy.views.UVDetailFormView","title":"UVDetailFormView
","text":" Bases: CanViewMixin
, DetailFormView
Display every comment of an UV and detailed infos about it.
Allow to comment the UV.
"},{"location":"reference/pedagogy/views/#pedagogy.views.UVCommentUpdateView","title":"UVCommentUpdateView
","text":" Bases: CanEditPropMixin
, UpdateView
Allow edit of a given comment.
"},{"location":"reference/pedagogy/views/#pedagogy.views.UVCommentDeleteView","title":"UVCommentDeleteView
","text":" Bases: CanEditPropMixin
, DeleteView
Allow delete of a given comment.
"},{"location":"reference/pedagogy/views/#pedagogy.views.UVGuideView","title":"UVGuideView
","text":" Bases: LoginRequiredMixin
, FormerSubscriberMixin
, TemplateView
UV guide main page.
"},{"location":"reference/pedagogy/views/#pedagogy.views.UVCommentReportCreateView","title":"UVCommentReportCreateView
","text":" Bases: CanCreateMixin
, CreateView
Create a new report for an inapropriate comment.
"},{"location":"reference/pedagogy/views/#pedagogy.views.UVModerationFormView","title":"UVModerationFormView
","text":" Bases: FormView
Moderation interface (Privileged).
"},{"location":"reference/pedagogy/views/#pedagogy.views.UVCreateView","title":"UVCreateView
","text":" Bases: CanCreateMixin
, CreateView
Add a new UV (Privileged).
"},{"location":"reference/pedagogy/views/#pedagogy.views.UVDeleteView","title":"UVDeleteView
","text":" Bases: CanEditPropMixin
, DeleteView
Allow to delete an UV (Privileged).
"},{"location":"reference/pedagogy/views/#pedagogy.views.UVUpdateView","title":"UVUpdateView
","text":" Bases: CanEditPropMixin
, UpdateView
Allow to edit an UV (Privilegied).
"},{"location":"reference/rootplace/models/","title":"Models","text":""},{"location":"reference/rootplace/views/","title":"Views","text":""},{"location":"reference/rootplace/views/#rootplace.views.OperationLog","title":"OperationLog
","text":" Bases: Model
General purpose log object to register operations.
"},{"location":"reference/rootplace/views/#rootplace.views.SithFile","title":"SithFile
","text":" Bases: Model
clean()
","text":"Cleans up the file.
Source code incore/models.py
def clean(self):\n \"\"\"Cleans up the file.\"\"\"\n super().clean()\n if \"/\" in self.name:\n raise ValidationError(_(\"Character '/' not authorized in name\"))\n if self == self.parent:\n raise ValidationError(_(\"Loop in folder tree\"), code=\"loop\")\n if self == self.parent or (\n self.parent is not None and self in self.get_parent_list()\n ):\n raise ValidationError(_(\"Loop in folder tree\"), code=\"loop\")\n if self.parent and self.parent.is_file:\n raise ValidationError(\n _(\"You can not make a file be a children of a non folder file\")\n )\n if (\n self.parent is None\n and SithFile.objects.exclude(id=self.id)\n .filter(parent=None, name=self.name)\n .exists()\n ) or (\n self.parent\n and self.parent.children.exclude(id=self.id).filter(name=self.name).exists()\n ):\n raise ValidationError(_(\"Duplicate file\"), code=\"duplicate\")\n if self.is_folder:\n if self.file:\n try:\n import imghdr\n\n if imghdr.what(None, self.file.read()) not in [\n \"gif\",\n \"png\",\n \"jpeg\",\n ]:\n self.file.delete()\n self.file = None\n except: # noqa E722 I don't know the exception that can be raised\n self.file = None\n self.mime_type = \"inode/directory\"\n if self.is_file and (self.file is None or self.file == \"\"):\n raise ValidationError(_(\"You must provide a file\"))\n
"},{"location":"reference/rootplace/views/#rootplace.views.SithFile.apply_rights_recursively","title":"apply_rights_recursively(*, only_folders=False)
","text":"Apply the rights of this file to all children recursively.
Parameters:
Name Type Description Defaultonly_folders
bool
If True, only apply the rights to SithFiles that are folders.
False
Source code in core/models.py
def apply_rights_recursively(self, *, only_folders: bool = False) -> None:\n \"\"\"Apply the rights of this file to all children recursively.\n\n Args:\n only_folders: If True, only apply the rights to SithFiles that are folders.\n \"\"\"\n file_ids = []\n explored_ids = [self.id]\n while len(explored_ids) > 0: # find all children recursively\n file_ids.extend(explored_ids)\n next_level = SithFile.objects.filter(parent_id__in=explored_ids)\n if only_folders:\n next_level = next_level.filter(is_folder=True)\n explored_ids = list(next_level.values_list(\"id\", flat=True))\n for through in (SithFile.view_groups.through, SithFile.edit_groups.through):\n # force evaluation. Without this, the iterator yields nothing\n groups = list(\n through.objects.filter(sithfile_id=self.id).values_list(\n \"group_id\", flat=True\n )\n )\n # delete previous rights\n through.objects.filter(sithfile_id__in=file_ids).delete()\n through.objects.bulk_create( # create new rights\n [through(sithfile_id=f, group_id=g) for f in file_ids for g in groups]\n )\n
"},{"location":"reference/rootplace/views/#rootplace.views.SithFile.copy_rights","title":"copy_rights()
","text":"Copy, if possible, the rights of the parent folder.
Source code incore/models.py
def copy_rights(self):\n \"\"\"Copy, if possible, the rights of the parent folder.\"\"\"\n if self.parent is not None:\n self.edit_groups.set(self.parent.edit_groups.all())\n self.view_groups.set(self.parent.view_groups.all())\n
"},{"location":"reference/rootplace/views/#rootplace.views.SithFile.move_to","title":"move_to(parent)
","text":"Move a file to a new parent. parent
must be a SithFile with the is_folder=True
property. Otherwise, this function doesn't change anything. This is done only at the DB level, so that it's very fast for the user. Indeed, this function doesn't modify SithFiles recursively, so it stays efficient even with top-level folders.
core/models.py
def move_to(self, parent):\n \"\"\"Move a file to a new parent.\n `parent` must be a SithFile with the `is_folder=True` property. Otherwise, this function doesn't change\n anything.\n This is done only at the DB level, so that it's very fast for the user. Indeed, this function doesn't modify\n SithFiles recursively, so it stays efficient even with top-level folders.\n \"\"\"\n if not parent.is_folder:\n return\n self.parent = parent\n self.clean()\n self.save()\n
"},{"location":"reference/rootplace/views/#rootplace.views.User","title":"User
","text":" Bases: AbstractBaseUser
Defines the base user class, useable in every app.
This is almost the same as the auth module AbstractUser since it inherits from it, but some fields are required, and the username is generated automatically with the name of the user (see generate_username()).
Added field: nick_name, date_of_birth Required fields: email, first_name, last_name, date_of_birth
"},{"location":"reference/rootplace/views/#rootplace.views.User.cached_groups","title":"cached_groups: list[Group]
property
","text":"Get the list of groups this user is in.
The result is cached for the default duration (should be 5 minutes)
Returns: A list of all the groups this user is in.
"},{"location":"reference/rootplace/views/#rootplace.views.User.is_in_group","title":"is_in_group(*, pk=None, name=None)
","text":"Check if this user is in the given group. Either a group id or a group name must be provided. If both are passed, only the id will be considered.
The group will be fetched using the given parameter. If no group is found, return False. If a group is found, check if this user is in the latter.
Returns:
Type Descriptionbool
True if the user is the group, else False
Source code incore/models.py
def is_in_group(self, *, pk: int | None = None, name: str | None = None) -> bool:\n \"\"\"Check if this user is in the given group.\n Either a group id or a group name must be provided.\n If both are passed, only the id will be considered.\n\n The group will be fetched using the given parameter.\n If no group is found, return False.\n If a group is found, check if this user is in the latter.\n\n Returns:\n True if the user is the group, else False\n \"\"\"\n if pk is not None:\n group: Optional[Group] = get_group(pk=pk)\n elif name is not None:\n group: Optional[Group] = get_group(name=name)\n else:\n raise ValueError(\"You must either provide the id or the name of the group\")\n if group is None:\n return False\n if group.id == settings.SITH_GROUP_PUBLIC_ID:\n return True\n if group.id == settings.SITH_GROUP_SUBSCRIBERS_ID:\n return self.is_subscribed\n if group.id == settings.SITH_GROUP_OLD_SUBSCRIBERS_ID:\n return self.was_subscribed\n if group.id == settings.SITH_GROUP_ROOT_ID:\n return self.is_root\n if group.is_meta:\n # check if this group is associated with a club\n group.__class__ = MetaGroup\n club = group.associated_club\n if club is None:\n return False\n membership = club.get_membership_for(self)\n if membership is None:\n return False\n if group.name.endswith(settings.SITH_MEMBER_SUFFIX):\n return True\n return membership.role > settings.SITH_MAXIMUM_FREE_ROLE\n return group in self.cached_groups\n
"},{"location":"reference/rootplace/views/#rootplace.views.User.age","title":"age()
","text":"Return the age this user has the day the method is called. If the user has not filled his age, return 0.
Source code incore/models.py
@cached_property\ndef age(self) -> int:\n \"\"\"Return the age this user has the day the method is called.\n If the user has not filled his age, return 0.\n \"\"\"\n if self.date_of_birth is None:\n return 0\n today = timezone.now()\n age = today.year - self.date_of_birth.year\n # remove a year if this year's birthday is yet to come\n age -= (today.month, today.day) < (\n self.date_of_birth.month,\n self.date_of_birth.day,\n )\n return age\n
"},{"location":"reference/rootplace/views/#rootplace.views.User.get_full_name","title":"get_full_name()
","text":"Returns the first_name plus the last_name, with a space in between.
Source code incore/models.py
def get_full_name(self):\n \"\"\"Returns the first_name plus the last_name, with a space in between.\"\"\"\n full_name = \"%s %s\" % (self.first_name, self.last_name)\n return full_name.strip()\n
"},{"location":"reference/rootplace/views/#rootplace.views.User.get_short_name","title":"get_short_name()
","text":"Returns the short name for the user.
Source code incore/models.py
def get_short_name(self):\n \"\"\"Returns the short name for the user.\"\"\"\n if self.nick_name:\n return self.nick_name\n return self.first_name + \" \" + self.last_name\n
"},{"location":"reference/rootplace/views/#rootplace.views.User.get_display_name","title":"get_display_name()
","text":"Returns the display name of the user.
A nickname if possible, otherwise, the full name.
Source code incore/models.py
def get_display_name(self) -> str:\n \"\"\"Returns the display name of the user.\n\n A nickname if possible, otherwise, the full name.\n \"\"\"\n if self.nick_name:\n return \"%s (%s)\" % (self.get_full_name(), self.nick_name)\n return self.get_full_name()\n
"},{"location":"reference/rootplace/views/#rootplace.views.User.get_age","title":"get_age()
","text":"Returns the age.
Source code incore/models.py
def get_age(self):\n \"\"\"Returns the age.\"\"\"\n today = timezone.now()\n born = self.date_of_birth\n return (\n today.year - born.year - ((today.month, today.day) < (born.month, born.day))\n )\n
"},{"location":"reference/rootplace/views/#rootplace.views.User.get_family","title":"get_family(godfathers_depth=4, godchildren_depth=4)
","text":"Get the family of the user, with the given depth.
Parameters:
Name Type Description Defaultgodfathers_depth
NonNegativeInt
The number of generations of godfathers to fetch
4
godchildren_depth
NonNegativeInt
The number of generations of godchildren to fetch
4
Returns:
Type Descriptionset[through]
A list of family relationships in this user's family
Source code incore/models.py
def get_family(\n self,\n godfathers_depth: NonNegativeInt = 4,\n godchildren_depth: NonNegativeInt = 4,\n) -> set[User.godfathers.through]:\n \"\"\"Get the family of the user, with the given depth.\n\n Args:\n godfathers_depth: The number of generations of godfathers to fetch\n godchildren_depth: The number of generations of godchildren to fetch\n\n Returns:\n A list of family relationships in this user's family\n \"\"\"\n res = []\n for depth, key, reverse_key in [\n (godfathers_depth, \"from_user_id\", \"to_user_id\"),\n (godchildren_depth, \"to_user_id\", \"from_user_id\"),\n ]:\n if depth == 0:\n continue\n links = list(User.godfathers.through.objects.filter(**{key: self.id}))\n res.extend(links)\n for _ in range(1, depth): # noqa: F402 we don't care about gettext here\n ids = [getattr(c, reverse_key) for c in links]\n links = list(\n User.godfathers.through.objects.filter(\n **{f\"{key}__in\": ids}\n ).exclude(id__in=[r.id for r in res])\n )\n if not links:\n break\n res.extend(links)\n return set(res)\n
"},{"location":"reference/rootplace/views/#rootplace.views.User.email_user","title":"email_user(subject, message, from_email=None, **kwargs)
","text":"Sends an email to this User.
Source code incore/models.py
def email_user(self, subject, message, from_email=None, **kwargs):\n \"\"\"Sends an email to this User.\"\"\"\n if from_email is None:\n from_email = settings.DEFAULT_FROM_EMAIL\n send_mail(subject, message, from_email, [self.email], **kwargs)\n
"},{"location":"reference/rootplace/views/#rootplace.views.User.generate_username","title":"generate_username()
","text":"Generates a unique username based on the first and last names.
For example: Guy Carlier gives gcarlier, and gcarlier1 if the first one exists.
Returns:
Type Descriptionstr
The generated username.
Source code incore/models.py
def generate_username(self) -> str:\n \"\"\"Generates a unique username based on the first and last names.\n\n For example: Guy Carlier gives gcarlier, and gcarlier1 if the first one exists.\n\n Returns:\n The generated username.\n \"\"\"\n\n def remove_accents(data):\n return \"\".join(\n x\n for x in unicodedata.normalize(\"NFKD\", data)\n if unicodedata.category(x)[0] == \"L\"\n ).lower()\n\n user_name = (\n remove_accents(self.first_name[0] + self.last_name)\n .encode(\"ascii\", \"ignore\")\n .decode(\"utf-8\")\n )\n un_set = [u.username for u in User.objects.all()]\n if user_name in un_set:\n i = 1\n while user_name + str(i) in un_set:\n i += 1\n user_name += str(i)\n self.username = user_name\n return user_name\n
"},{"location":"reference/rootplace/views/#rootplace.views.User.is_owner","title":"is_owner(obj)
","text":"Determine if the object is owned by the user.
Source code incore/models.py
def is_owner(self, obj):\n \"\"\"Determine if the object is owned by the user.\"\"\"\n if hasattr(obj, \"is_owned_by\") and obj.is_owned_by(self):\n return True\n if hasattr(obj, \"owner_group\") and self.is_in_group(pk=obj.owner_group.id):\n return True\n return self.is_root\n
"},{"location":"reference/rootplace/views/#rootplace.views.User.can_edit","title":"can_edit(obj)
","text":"Determine if the object can be edited by the user.
Source code incore/models.py
def can_edit(self, obj):\n \"\"\"Determine if the object can be edited by the user.\"\"\"\n if hasattr(obj, \"can_be_edited_by\") and obj.can_be_edited_by(self):\n return True\n if hasattr(obj, \"edit_groups\"):\n for pk in obj.edit_groups.values_list(\"pk\", flat=True):\n if self.is_in_group(pk=pk):\n return True\n if isinstance(obj, User) and obj == self:\n return True\n return self.is_owner(obj)\n
"},{"location":"reference/rootplace/views/#rootplace.views.User.can_view","title":"can_view(obj)
","text":"Determine if the object can be viewed by the user.
Source code incore/models.py
def can_view(self, obj):\n \"\"\"Determine if the object can be viewed by the user.\"\"\"\n if hasattr(obj, \"can_be_viewed_by\") and obj.can_be_viewed_by(self):\n return True\n if hasattr(obj, \"view_groups\"):\n for pk in obj.view_groups.values_list(\"pk\", flat=True):\n if self.is_in_group(pk=pk):\n return True\n return self.can_edit(obj)\n
"},{"location":"reference/rootplace/views/#rootplace.views.User.clubs_with_rights","title":"clubs_with_rights()
","text":"The list of clubs where the user has rights
Source code incore/models.py
@cached_property\ndef clubs_with_rights(self) -> list[Club]:\n \"\"\"The list of clubs where the user has rights\"\"\"\n memberships = self.memberships.ongoing().board().select_related(\"club\")\n return [m.club for m in memberships]\n
"},{"location":"reference/rootplace/views/#rootplace.views.CanEditPropMixin","title":"CanEditPropMixin
","text":" Bases: GenericContentPermissionMixinBuilder
Ensure the user has owner permissions on the child view object.
In other word, you can make a view with this view as parent, and it will be retricted to the users that are in the object's owner_group or that pass the obj.can_be_viewed_by
test.
Raises:
Type DescriptionPermissionDenied
If the user cannot see the object
"},{"location":"reference/rootplace/views/#rootplace.views.Customer","title":"Customer
","text":" Bases: Model
Customer data of a User.
It adds some basic customers' information, such as the account ID, and is used by other accounting classes as reference to the customer, rather than using User.
"},{"location":"reference/rootplace/views/#rootplace.views.Customer.can_buy","title":"can_buy: bool
property
","text":"Check if whether this customer has the right to purchase any item.
This must be not confused with the Product.can_be_sold_to(user) method as the present method returns an information about a customer whereas the other tells something about the relation between a User (not a Customer, don't mix them) and a Product.
"},{"location":"reference/rootplace/views/#rootplace.views.Customer.save","title":"save(*args, allow_negative=False, is_selling=False, **kwargs)
","text":"is_selling : tell if the current action is a selling allow_negative : ignored if not a selling. Allow a selling to put the account in negative Those two parameters avoid blocking the save method of a customer if his account is negative.
Source code incounter/models.py
def save(self, *args, allow_negative=False, is_selling=False, **kwargs):\n \"\"\"is_selling : tell if the current action is a selling\n allow_negative : ignored if not a selling. Allow a selling to put the account in negative\n Those two parameters avoid blocking the save method of a customer if his account is negative.\n \"\"\"\n if self.amount < 0 and (is_selling and not allow_negative):\n raise ValidationError(_(\"Not enough money\"))\n super().save(*args, **kwargs)\n
"},{"location":"reference/rootplace/views/#rootplace.views.Customer.get_or_create","title":"get_or_create(user)
classmethod
","text":"Work in pretty much the same way as the usual get_or_create method, but with the default field replaced by some under the hood.
If the user has an account, return it as is. Else create a new account with no money on it and a new unique account id
Example : ::
user = User.objects.get(pk=1)\naccount, created = Customer.get_or_create(user)\nif created:\n print(f\"created a new account with id {account.id}\")\nelse:\n print(f\"user has already an account, with {account.id} \u20ac on it\"\n
Source code in counter/models.py
@classmethod\ndef get_or_create(cls, user: User) -> Tuple[Customer, bool]:\n \"\"\"Work in pretty much the same way as the usual get_or_create method,\n but with the default field replaced by some under the hood.\n\n If the user has an account, return it as is.\n Else create a new account with no money on it and a new unique account id\n\n Example : ::\n\n user = User.objects.get(pk=1)\n account, created = Customer.get_or_create(user)\n if created:\n print(f\"created a new account with id {account.id}\")\n else:\n print(f\"user has already an account, with {account.id} \u20ac on it\"\n \"\"\"\n if hasattr(user, \"customer\"):\n return user.customer, False\n\n # account_id are always a number with a letter appended\n account_id = (\n Customer.objects.order_by(Length(\"account_id\"), \"account_id\")\n .values(\"account_id\")\n .last()\n )\n if account_id is None:\n # legacy from the old site\n account = cls.objects.create(user=user, account_id=\"1504a\")\n return account, True\n\n account_id = account_id[\"account_id\"]\n account_num = int(account_id[:-1])\n while Customer.objects.filter(account_id=account_id).exists():\n # when entering the first iteration, we are using an already existing account id\n # so the loop should always execute at least one time\n account_num += 1\n account_id = f\"{account_num}{random.choice(string.ascii_lowercase)}\"\n\n account = cls.objects.create(user=user, account_id=account_id)\n return account, True\n
"},{"location":"reference/rootplace/views/#rootplace.views.ForumMessageMeta","title":"ForumMessageMeta
","text":" Bases: Model
MergeForm
","text":" Bases: Form
SelectUserForm
","text":" Bases: Form
MergeUsersView
","text":" Bases: FormView
DeleteAllForumUserMessagesView
","text":" Bases: FormView
Delete all forum messages from an user.
Messages are soft deleted and are still visible from admins GUI frontend to the dedicated command.
"},{"location":"reference/rootplace/views/#rootplace.views.OperationLogListView","title":"OperationLogListView
","text":" Bases: ListView
, CanEditPropMixin
List all logs.
"},{"location":"reference/rootplace/views/#rootplace.views.__merge_subscriptions","title":"__merge_subscriptions(u1, u2)
","text":"Give all the subscriptions of the second user to first one.
If some subscriptions are still active, update their end date to increase the overall subscription time of the first user.
Some examples : - if u1 is not subscribed, his subscription end date become the one of u2 - if u1 is subscribed but not u2, nothing happen - if u1 is subscribed for, let's say, 2 remaining months and u2 is subscribed for 3 remaining months, he shall then be subscribed for 5 months
Source code inrootplace/views.py
def __merge_subscriptions(u1: User, u2: User):\n \"\"\"Give all the subscriptions of the second user to first one.\n\n If some subscriptions are still active, update their end date\n to increase the overall subscription time of the first user.\n\n Some examples :\n - if u1 is not subscribed, his subscription end date become the one of u2\n - if u1 is subscribed but not u2, nothing happen\n - if u1 is subscribed for, let's say,\n 2 remaining months and u2 is subscribed for 3 remaining months,\n he shall then be subscribed for 5 months\n \"\"\"\n last_subscription = (\n u1.subscriptions.filter(\n subscription_start__lte=timezone.now(), subscription_end__gte=timezone.now()\n )\n .order_by(\"subscription_end\")\n .last()\n )\n if last_subscription is not None:\n subscription_end = last_subscription.subscription_end\n for subscription in u2.subscriptions.filter(\n subscription_end__gte=timezone.now()\n ):\n subscription.subscription_start = subscription_end\n if subscription.subscription_start > localdate():\n remaining = subscription.subscription_end - localdate()\n else:\n remaining = (\n subscription.subscription_end - subscription.subscription_start\n )\n subscription_end += remaining\n subscription.subscription_end = subscription_end\n subscription.save()\n u2.subscriptions.all().update(member=u1)\n
"},{"location":"reference/rootplace/views/#rootplace.views.__merge_pictures","title":"__merge_pictures(u1, u2)
","text":"Source code in rootplace/views.py
def __merge_pictures(u1: User, u2: User) -> None:\n SithFile.objects.filter(owner=u2).update(owner=u1)\n if u1.profile_pict is None and u2.profile_pict is not None:\n u1.profile_pict, u2.profile_pict = u2.profile_pict, None\n if u1.scrub_pict is None and u2.scrub_pict is not None:\n u1.scrub_pict, u2.scrub_pict = u2.scrub_pict, None\n if u1.avatar_pict is None and u2.avatar_pict is not None:\n u1.avatar_pict, u2.avatar_pict = u2.avatar_pict, None\n u2.save()\n u1.save()\n
"},{"location":"reference/rootplace/views/#rootplace.views.merge_users","title":"merge_users(u1, u2)
","text":"Merge u2 into u1.
This means that u1 shall receive everything that belonged to u2 :
- pictures\n- refills of the sith account\n- purchases of any item bought on the eboutic or the counters\n- subscriptions\n- godfathers\n- godchildren\n
If u1 had no account id, he shall receive the one of u2. If u1 and u2 were both in the middle of a subscription, the remaining durations stack If u1 had no profile picture, he shall receive the one of u2
Source code inrootplace/views.py
def merge_users(u1: User, u2: User) -> User:\n \"\"\"Merge u2 into u1.\n\n This means that u1 shall receive everything that belonged to u2 :\n\n - pictures\n - refills of the sith account\n - purchases of any item bought on the eboutic or the counters\n - subscriptions\n - godfathers\n - godchildren\n\n If u1 had no account id, he shall receive the one of u2.\n If u1 and u2 were both in the middle of a subscription, the remaining\n durations stack\n If u1 had no profile picture, he shall receive the one of u2\n \"\"\"\n for field in u1._meta.fields:\n if not field.is_relation and not u1.__dict__[field.name]:\n u1.__dict__[field.name] = u2.__dict__[field.name]\n for group in u2.groups.all():\n u1.groups.add(group.id)\n for godfather in u2.godfathers.exclude(id=u1.id):\n u1.godfathers.add(godfather)\n for godchild in u2.godchildren.exclude(id=u1.id):\n u1.godchildren.add(godchild)\n __merge_subscriptions(u1, u2)\n __merge_pictures(u1, u2)\n u2.invoices.all().update(user=u1)\n c_src = Customer.objects.filter(user=u2).first()\n if c_src is not None:\n c_dest, created = Customer.get_or_create(u1)\n c_src.refillings.update(customer=c_dest)\n c_src.buyings.update(customer=c_dest)\n c_dest.recompute_amount()\n if created:\n # swap the account numbers, so that the user keep\n # the id he is accustomed to\n tmp_id = c_src.account_id\n # delete beforehand in order not to have a unique constraint violation\n c_src.delete()\n c_dest.account_id = tmp_id\n u1.save()\n u2.delete() # everything remaining in u2 gets deleted thanks to on_delete=CASCADE\n return u1\n
"},{"location":"reference/rootplace/views/#rootplace.views.delete_all_forum_user_messages","title":"delete_all_forum_user_messages(user, moderator, *, verbose=False)
","text":"Soft delete all messages of a user.
Parameters:
Name Type Description Defaultuser
User
core.models.User the user to delete messages from
requiredmoderator
User
core.models.User the one marked as the moderator.
requiredverbose
bool
bool if True, print the deleted messages
False
Source code in rootplace/views.py
def delete_all_forum_user_messages(\n user: User, moderator: User, *, verbose: bool = False\n):\n \"\"\"Soft delete all messages of a user.\n\n Args:\n user: core.models.User the user to delete messages from\n moderator: core.models.User the one marked as the moderator.\n verbose: bool if True, print the deleted messages\n \"\"\"\n for message in user.forum_messages.all():\n if message.is_deleted():\n continue\n\n if verbose:\n logging.getLogger(\"django\").info(message)\n ForumMessageMeta(message=message, user=moderator, action=\"DELETE\").save()\n
"},{"location":"reference/sas/models/","title":"Models","text":""},{"location":"reference/sas/models/#sas.models.SithFile","title":"SithFile
","text":" Bases: Model
clean()
","text":"Cleans up the file.
Source code incore/models.py
def clean(self):\n \"\"\"Cleans up the file.\"\"\"\n super().clean()\n if \"/\" in self.name:\n raise ValidationError(_(\"Character '/' not authorized in name\"))\n if self == self.parent:\n raise ValidationError(_(\"Loop in folder tree\"), code=\"loop\")\n if self == self.parent or (\n self.parent is not None and self in self.get_parent_list()\n ):\n raise ValidationError(_(\"Loop in folder tree\"), code=\"loop\")\n if self.parent and self.parent.is_file:\n raise ValidationError(\n _(\"You can not make a file be a children of a non folder file\")\n )\n if (\n self.parent is None\n and SithFile.objects.exclude(id=self.id)\n .filter(parent=None, name=self.name)\n .exists()\n ) or (\n self.parent\n and self.parent.children.exclude(id=self.id).filter(name=self.name).exists()\n ):\n raise ValidationError(_(\"Duplicate file\"), code=\"duplicate\")\n if self.is_folder:\n if self.file:\n try:\n import imghdr\n\n if imghdr.what(None, self.file.read()) not in [\n \"gif\",\n \"png\",\n \"jpeg\",\n ]:\n self.file.delete()\n self.file = None\n except: # noqa E722 I don't know the exception that can be raised\n self.file = None\n self.mime_type = \"inode/directory\"\n if self.is_file and (self.file is None or self.file == \"\"):\n raise ValidationError(_(\"You must provide a file\"))\n
"},{"location":"reference/sas/models/#sas.models.SithFile.apply_rights_recursively","title":"apply_rights_recursively(*, only_folders=False)
","text":"Apply the rights of this file to all children recursively.
Parameters:
Name Type Description Defaultonly_folders
bool
If True, only apply the rights to SithFiles that are folders.
False
Source code in core/models.py
def apply_rights_recursively(self, *, only_folders: bool = False) -> None:\n \"\"\"Apply the rights of this file to all children recursively.\n\n Args:\n only_folders: If True, only apply the rights to SithFiles that are folders.\n \"\"\"\n file_ids = []\n explored_ids = [self.id]\n while len(explored_ids) > 0: # find all children recursively\n file_ids.extend(explored_ids)\n next_level = SithFile.objects.filter(parent_id__in=explored_ids)\n if only_folders:\n next_level = next_level.filter(is_folder=True)\n explored_ids = list(next_level.values_list(\"id\", flat=True))\n for through in (SithFile.view_groups.through, SithFile.edit_groups.through):\n # force evaluation. Without this, the iterator yields nothing\n groups = list(\n through.objects.filter(sithfile_id=self.id).values_list(\n \"group_id\", flat=True\n )\n )\n # delete previous rights\n through.objects.filter(sithfile_id__in=file_ids).delete()\n through.objects.bulk_create( # create new rights\n [through(sithfile_id=f, group_id=g) for f in file_ids for g in groups]\n )\n
"},{"location":"reference/sas/models/#sas.models.SithFile.copy_rights","title":"copy_rights()
","text":"Copy, if possible, the rights of the parent folder.
Source code incore/models.py
def copy_rights(self):\n \"\"\"Copy, if possible, the rights of the parent folder.\"\"\"\n if self.parent is not None:\n self.edit_groups.set(self.parent.edit_groups.all())\n self.view_groups.set(self.parent.view_groups.all())\n
"},{"location":"reference/sas/models/#sas.models.SithFile.move_to","title":"move_to(parent)
","text":"Move a file to a new parent. parent
must be a SithFile with the is_folder=True
property. Otherwise, this function doesn't change anything. This is done only at the DB level, so that it's very fast for the user. Indeed, this function doesn't modify SithFiles recursively, so it stays efficient even with top-level folders.
core/models.py
def move_to(self, parent):\n \"\"\"Move a file to a new parent.\n `parent` must be a SithFile with the `is_folder=True` property. Otherwise, this function doesn't change\n anything.\n This is done only at the DB level, so that it's very fast for the user. Indeed, this function doesn't modify\n SithFiles recursively, so it stays efficient even with top-level folders.\n \"\"\"\n if not parent.is_folder:\n return\n self.parent = parent\n self.clean()\n self.save()\n
"},{"location":"reference/sas/models/#sas.models.User","title":"User
","text":" Bases: AbstractBaseUser
Defines the base user class, useable in every app.
This is almost the same as the auth module AbstractUser since it inherits from it, but some fields are required, and the username is generated automatically with the name of the user (see generate_username()).
Added field: nick_name, date_of_birth Required fields: email, first_name, last_name, date_of_birth
"},{"location":"reference/sas/models/#sas.models.User.cached_groups","title":"cached_groups: list[Group]
property
","text":"Get the list of groups this user is in.
The result is cached for the default duration (should be 5 minutes)
Returns: A list of all the groups this user is in.
"},{"location":"reference/sas/models/#sas.models.User.is_in_group","title":"is_in_group(*, pk=None, name=None)
","text":"Check if this user is in the given group. Either a group id or a group name must be provided. If both are passed, only the id will be considered.
The group will be fetched using the given parameter. If no group is found, return False. If a group is found, check if this user is in the latter.
Returns:
Type Descriptionbool
True if the user is the group, else False
Source code incore/models.py
def is_in_group(self, *, pk: int | None = None, name: str | None = None) -> bool:\n \"\"\"Check if this user is in the given group.\n Either a group id or a group name must be provided.\n If both are passed, only the id will be considered.\n\n The group will be fetched using the given parameter.\n If no group is found, return False.\n If a group is found, check if this user is in the latter.\n\n Returns:\n True if the user is the group, else False\n \"\"\"\n if pk is not None:\n group: Optional[Group] = get_group(pk=pk)\n elif name is not None:\n group: Optional[Group] = get_group(name=name)\n else:\n raise ValueError(\"You must either provide the id or the name of the group\")\n if group is None:\n return False\n if group.id == settings.SITH_GROUP_PUBLIC_ID:\n return True\n if group.id == settings.SITH_GROUP_SUBSCRIBERS_ID:\n return self.is_subscribed\n if group.id == settings.SITH_GROUP_OLD_SUBSCRIBERS_ID:\n return self.was_subscribed\n if group.id == settings.SITH_GROUP_ROOT_ID:\n return self.is_root\n if group.is_meta:\n # check if this group is associated with a club\n group.__class__ = MetaGroup\n club = group.associated_club\n if club is None:\n return False\n membership = club.get_membership_for(self)\n if membership is None:\n return False\n if group.name.endswith(settings.SITH_MEMBER_SUFFIX):\n return True\n return membership.role > settings.SITH_MAXIMUM_FREE_ROLE\n return group in self.cached_groups\n
"},{"location":"reference/sas/models/#sas.models.User.age","title":"age()
","text":"Return the age this user has the day the method is called. If the user has not filled his age, return 0.
Source code incore/models.py
@cached_property\ndef age(self) -> int:\n \"\"\"Return the age this user has the day the method is called.\n If the user has not filled his age, return 0.\n \"\"\"\n if self.date_of_birth is None:\n return 0\n today = timezone.now()\n age = today.year - self.date_of_birth.year\n # remove a year if this year's birthday is yet to come\n age -= (today.month, today.day) < (\n self.date_of_birth.month,\n self.date_of_birth.day,\n )\n return age\n
"},{"location":"reference/sas/models/#sas.models.User.get_full_name","title":"get_full_name()
","text":"Returns the first_name plus the last_name, with a space in between.
Source code incore/models.py
def get_full_name(self):\n \"\"\"Returns the first_name plus the last_name, with a space in between.\"\"\"\n full_name = \"%s %s\" % (self.first_name, self.last_name)\n return full_name.strip()\n
"},{"location":"reference/sas/models/#sas.models.User.get_short_name","title":"get_short_name()
","text":"Returns the short name for the user.
Source code incore/models.py
def get_short_name(self):\n \"\"\"Returns the short name for the user.\"\"\"\n if self.nick_name:\n return self.nick_name\n return self.first_name + \" \" + self.last_name\n
"},{"location":"reference/sas/models/#sas.models.User.get_display_name","title":"get_display_name()
","text":"Returns the display name of the user.
A nickname if possible, otherwise, the full name.
Source code incore/models.py
def get_display_name(self) -> str:\n \"\"\"Returns the display name of the user.\n\n A nickname if possible, otherwise, the full name.\n \"\"\"\n if self.nick_name:\n return \"%s (%s)\" % (self.get_full_name(), self.nick_name)\n return self.get_full_name()\n
"},{"location":"reference/sas/models/#sas.models.User.get_age","title":"get_age()
","text":"Returns the age.
Source code incore/models.py
def get_age(self):\n \"\"\"Returns the age.\"\"\"\n today = timezone.now()\n born = self.date_of_birth\n return (\n today.year - born.year - ((today.month, today.day) < (born.month, born.day))\n )\n
"},{"location":"reference/sas/models/#sas.models.User.get_family","title":"get_family(godfathers_depth=4, godchildren_depth=4)
","text":"Get the family of the user, with the given depth.
Parameters:
Name Type Description Defaultgodfathers_depth
NonNegativeInt
The number of generations of godfathers to fetch
4
godchildren_depth
NonNegativeInt
The number of generations of godchildren to fetch
4
Returns:
Type Descriptionset[through]
A list of family relationships in this user's family
Source code incore/models.py
def get_family(\n self,\n godfathers_depth: NonNegativeInt = 4,\n godchildren_depth: NonNegativeInt = 4,\n) -> set[User.godfathers.through]:\n \"\"\"Get the family of the user, with the given depth.\n\n Args:\n godfathers_depth: The number of generations of godfathers to fetch\n godchildren_depth: The number of generations of godchildren to fetch\n\n Returns:\n A list of family relationships in this user's family\n \"\"\"\n res = []\n for depth, key, reverse_key in [\n (godfathers_depth, \"from_user_id\", \"to_user_id\"),\n (godchildren_depth, \"to_user_id\", \"from_user_id\"),\n ]:\n if depth == 0:\n continue\n links = list(User.godfathers.through.objects.filter(**{key: self.id}))\n res.extend(links)\n for _ in range(1, depth): # noqa: F402 we don't care about gettext here\n ids = [getattr(c, reverse_key) for c in links]\n links = list(\n User.godfathers.through.objects.filter(\n **{f\"{key}__in\": ids}\n ).exclude(id__in=[r.id for r in res])\n )\n if not links:\n break\n res.extend(links)\n return set(res)\n
"},{"location":"reference/sas/models/#sas.models.User.email_user","title":"email_user(subject, message, from_email=None, **kwargs)
","text":"Sends an email to this User.
Source code incore/models.py
def email_user(self, subject, message, from_email=None, **kwargs):\n \"\"\"Sends an email to this User.\"\"\"\n if from_email is None:\n from_email = settings.DEFAULT_FROM_EMAIL\n send_mail(subject, message, from_email, [self.email], **kwargs)\n
"},{"location":"reference/sas/models/#sas.models.User.generate_username","title":"generate_username()
","text":"Generates a unique username based on the first and last names.
For example: Guy Carlier gives gcarlier, and gcarlier1 if the first one exists.
Returns:
Type Descriptionstr
The generated username.
Source code incore/models.py
def generate_username(self) -> str:\n \"\"\"Generates a unique username based on the first and last names.\n\n For example: Guy Carlier gives gcarlier, and gcarlier1 if the first one exists.\n\n Returns:\n The generated username.\n \"\"\"\n\n def remove_accents(data):\n return \"\".join(\n x\n for x in unicodedata.normalize(\"NFKD\", data)\n if unicodedata.category(x)[0] == \"L\"\n ).lower()\n\n user_name = (\n remove_accents(self.first_name[0] + self.last_name)\n .encode(\"ascii\", \"ignore\")\n .decode(\"utf-8\")\n )\n un_set = [u.username for u in User.objects.all()]\n if user_name in un_set:\n i = 1\n while user_name + str(i) in un_set:\n i += 1\n user_name += str(i)\n self.username = user_name\n return user_name\n
"},{"location":"reference/sas/models/#sas.models.User.is_owner","title":"is_owner(obj)
","text":"Determine if the object is owned by the user.
Source code incore/models.py
def is_owner(self, obj):\n \"\"\"Determine if the object is owned by the user.\"\"\"\n if hasattr(obj, \"is_owned_by\") and obj.is_owned_by(self):\n return True\n if hasattr(obj, \"owner_group\") and self.is_in_group(pk=obj.owner_group.id):\n return True\n return self.is_root\n
"},{"location":"reference/sas/models/#sas.models.User.can_edit","title":"can_edit(obj)
","text":"Determine if the object can be edited by the user.
Source code incore/models.py
def can_edit(self, obj):\n \"\"\"Determine if the object can be edited by the user.\"\"\"\n if hasattr(obj, \"can_be_edited_by\") and obj.can_be_edited_by(self):\n return True\n if hasattr(obj, \"edit_groups\"):\n for pk in obj.edit_groups.values_list(\"pk\", flat=True):\n if self.is_in_group(pk=pk):\n return True\n if isinstance(obj, User) and obj == self:\n return True\n return self.is_owner(obj)\n
"},{"location":"reference/sas/models/#sas.models.User.can_view","title":"can_view(obj)
","text":"Determine if the object can be viewed by the user.
Source code incore/models.py
def can_view(self, obj):\n \"\"\"Determine if the object can be viewed by the user.\"\"\"\n if hasattr(obj, \"can_be_viewed_by\") and obj.can_be_viewed_by(self):\n return True\n if hasattr(obj, \"view_groups\"):\n for pk in obj.view_groups.values_list(\"pk\", flat=True):\n if self.is_in_group(pk=pk):\n return True\n return self.can_edit(obj)\n
"},{"location":"reference/sas/models/#sas.models.User.clubs_with_rights","title":"clubs_with_rights()
","text":"The list of clubs where the user has rights
Source code incore/models.py
@cached_property\ndef clubs_with_rights(self) -> list[Club]:\n \"\"\"The list of clubs where the user has rights\"\"\"\n memberships = self.memberships.ongoing().board().select_related(\"club\")\n return [m.club for m in memberships]\n
"},{"location":"reference/sas/models/#sas.models.SasFile","title":"SasFile
","text":" Bases: SithFile
Proxy model for any file in the SAS.
May be used to have logic that should be shared by both Picture and Album.
"},{"location":"reference/sas/models/#sas.models.PictureQuerySet","title":"PictureQuerySet
","text":" Bases: QuerySet
viewable_by(user)
","text":"Filter the pictures that this user can view.
WarningCalling this queryset method may add several additional requests.
Source code insas/models.py
def viewable_by(self, user: User) -> Self:\n \"\"\"Filter the pictures that this user can view.\n\n Warning:\n Calling this queryset method may add several additional requests.\n \"\"\"\n if user.is_root or user.is_in_group(pk=settings.SITH_GROUP_SAS_ADMIN_ID):\n return self.all()\n if user.was_subscribed:\n return self.filter(is_moderated=True)\n return self.filter(people__user_id=user.id, is_moderated=True)\n
"},{"location":"reference/sas/models/#sas.models.SASPictureManager","title":"SASPictureManager
","text":" Bases: Manager
Picture
","text":" Bases: SasFile
AlbumQuerySet
","text":" Bases: QuerySet
viewable_by(user)
","text":"Filter the albums that this user can view.
WarningCalling this queryset method may add several additional requests.
Source code insas/models.py
def viewable_by(self, user: User) -> Self:\n \"\"\"Filter the albums that this user can view.\n\n Warning:\n Calling this queryset method may add several additional requests.\n \"\"\"\n if user.is_root or user.is_in_group(pk=settings.SITH_GROUP_SAS_ADMIN_ID):\n return self.all()\n if user.was_subscribed:\n return self.filter(is_moderated=True)\n # known bug : if all children of an album are also albums\n # then this album is excluded, even if one of the sub-albums should be visible.\n # The fs-like navigation is likely to be half-broken for non-subscribers,\n # but that's ok, since non-subscribers are expected to see only the albums\n # containing pictures on which they have been identified (hence, very few).\n # Most, if not all, of their albums will be displayed on the\n # `latest albums` section of the SAS.\n # Moreover, they will still see all of their picture in their profile.\n return self.filter(\n Exists(Picture.objects.filter(parent_id=OuterRef(\"pk\")).viewable_by(user))\n )\n
"},{"location":"reference/sas/models/#sas.models.SASAlbumManager","title":"SASAlbumManager
","text":" Bases: Manager
Album
","text":" Bases: SasFile
NAME_MAX_LENGTH: int = 50
class-attribute
","text":"Maximum length of an album's name.
SithFile have a maximum length of 256 characters. However, this limit is too high for albums. Names longer than 50 characters are harder to read and harder to display on the SAS page.
It is to be noted, though, that this does not add or modify any db behaviour. It's just a constant to be used in views and forms.
"},{"location":"reference/sas/models/#sas.models.PeoplePictureRelation","title":"PeoplePictureRelation
","text":" Bases: Model
The PeoplePictureRelation class makes the connection between User and Picture.
"},{"location":"reference/sas/models/#sas.models.PictureModerationRequest","title":"PictureModerationRequest
","text":" Bases: Model
A request to remove a Picture from the SAS.
"},{"location":"reference/sas/models/#sas.models.exif_auto_rotate","title":"exif_auto_rotate(image)
","text":"Source code in core/utils.py
def exif_auto_rotate(image):\n for orientation in ExifTags.TAGS:\n if ExifTags.TAGS[orientation] == \"Orientation\":\n break\n exif = dict(image._getexif().items())\n\n if exif[orientation] == 3:\n image = image.rotate(180, expand=True)\n elif exif[orientation] == 6:\n image = image.rotate(270, expand=True)\n elif exif[orientation] == 8:\n image = image.rotate(90, expand=True)\n\n return image\n
"},{"location":"reference/sas/models/#sas.models.resize_image","title":"resize_image(im, edge, img_format, *, optimize=True)
","text":"Resize an image to fit the given edge length and format.
Parameters:
Name Type Description Defaultim
Image
the image to resize
requirededge
int
the length that the greater side of the resized image should have
requiredimg_format
str
the target format of the image (\"JPEG\", \"PNG\", \"WEBP\"...)
requiredoptimize
bool
Should the resized image be optimized ?
True
Source code in core/utils.py
def resize_image(\n im: Image, edge: int, img_format: str, *, optimize: bool = True\n) -> ContentFile:\n \"\"\"Resize an image to fit the given edge length and format.\n\n Args:\n im: the image to resize\n edge: the length that the greater side of the resized image should have\n img_format: the target format of the image (\"JPEG\", \"PNG\", \"WEBP\"...)\n optimize: Should the resized image be optimized ?\n \"\"\"\n (w, h) = im.size\n ratio = edge / max(w, h)\n (width, height) = int(w * ratio), int(h * ratio)\n return resize_image_explicit(im, (width, height), img_format, optimize=optimize)\n
"},{"location":"reference/sas/models/#sas.models.sas_notification_callback","title":"sas_notification_callback(notif)
","text":"Source code in sas/models.py
def sas_notification_callback(notif):\n count = Picture.objects.filter(is_moderated=False).count()\n if count:\n notif.viewed = False\n else:\n notif.viewed = True\n notif.param = \"%s\" % count\n notif.date = timezone.now()\n
"},{"location":"reference/sas/schemas/","title":"Schemas","text":""},{"location":"reference/sas/schemas/#sas.schemas.SimpleUserSchema","title":"SimpleUserSchema
","text":" Bases: ModelSchema
A schema with the minimum amount of information to represent a user.
"},{"location":"reference/sas/schemas/#sas.schemas.UserProfileSchema","title":"UserProfileSchema
","text":" Bases: ModelSchema
The necessary information to show a user profile
"},{"location":"reference/sas/schemas/#sas.schemas.Album","title":"Album
","text":" Bases: SasFile
NAME_MAX_LENGTH: int = 50
class-attribute
","text":"Maximum length of an album's name.
SithFile have a maximum length of 256 characters. However, this limit is too high for albums. Names longer than 50 characters are harder to read and harder to display on the SAS page.
It is to be noted, though, that this does not add or modify any db behaviour. It's just a constant to be used in views and forms.
"},{"location":"reference/sas/schemas/#sas.schemas.Picture","title":"Picture
","text":" Bases: SasFile
PictureModerationRequest
","text":" Bases: Model
A request to remove a Picture from the SAS.
"},{"location":"reference/sas/schemas/#sas.schemas.AlbumSchema","title":"AlbumSchema
","text":" Bases: ModelSchema
PictureFilterSchema
","text":" Bases: FilterSchema
PictureSchema
","text":" Bases: ModelSchema
PictureRelationCreationSchema
","text":" Bases: Schema
IdentifiedUserSchema
","text":" Bases: Schema
ModerationRequestSchema
","text":" Bases: ModelSchema
SithFile
","text":" Bases: Model
clean()
","text":"Cleans up the file.
Source code incore/models.py
def clean(self):\n \"\"\"Cleans up the file.\"\"\"\n super().clean()\n if \"/\" in self.name:\n raise ValidationError(_(\"Character '/' not authorized in name\"))\n if self == self.parent:\n raise ValidationError(_(\"Loop in folder tree\"), code=\"loop\")\n if self == self.parent or (\n self.parent is not None and self in self.get_parent_list()\n ):\n raise ValidationError(_(\"Loop in folder tree\"), code=\"loop\")\n if self.parent and self.parent.is_file:\n raise ValidationError(\n _(\"You can not make a file be a children of a non folder file\")\n )\n if (\n self.parent is None\n and SithFile.objects.exclude(id=self.id)\n .filter(parent=None, name=self.name)\n .exists()\n ) or (\n self.parent\n and self.parent.children.exclude(id=self.id).filter(name=self.name).exists()\n ):\n raise ValidationError(_(\"Duplicate file\"), code=\"duplicate\")\n if self.is_folder:\n if self.file:\n try:\n import imghdr\n\n if imghdr.what(None, self.file.read()) not in [\n \"gif\",\n \"png\",\n \"jpeg\",\n ]:\n self.file.delete()\n self.file = None\n except: # noqa E722 I don't know the exception that can be raised\n self.file = None\n self.mime_type = \"inode/directory\"\n if self.is_file and (self.file is None or self.file == \"\"):\n raise ValidationError(_(\"You must provide a file\"))\n
"},{"location":"reference/sas/views/#sas.views.SithFile.apply_rights_recursively","title":"apply_rights_recursively(*, only_folders=False)
","text":"Apply the rights of this file to all children recursively.
Parameters:
Name Type Description Defaultonly_folders
bool
If True, only apply the rights to SithFiles that are folders.
False
Source code in core/models.py
def apply_rights_recursively(self, *, only_folders: bool = False) -> None:\n \"\"\"Apply the rights of this file to all children recursively.\n\n Args:\n only_folders: If True, only apply the rights to SithFiles that are folders.\n \"\"\"\n file_ids = []\n explored_ids = [self.id]\n while len(explored_ids) > 0: # find all children recursively\n file_ids.extend(explored_ids)\n next_level = SithFile.objects.filter(parent_id__in=explored_ids)\n if only_folders:\n next_level = next_level.filter(is_folder=True)\n explored_ids = list(next_level.values_list(\"id\", flat=True))\n for through in (SithFile.view_groups.through, SithFile.edit_groups.through):\n # force evaluation. Without this, the iterator yields nothing\n groups = list(\n through.objects.filter(sithfile_id=self.id).values_list(\n \"group_id\", flat=True\n )\n )\n # delete previous rights\n through.objects.filter(sithfile_id__in=file_ids).delete()\n through.objects.bulk_create( # create new rights\n [through(sithfile_id=f, group_id=g) for f in file_ids for g in groups]\n )\n
"},{"location":"reference/sas/views/#sas.views.SithFile.copy_rights","title":"copy_rights()
","text":"Copy, if possible, the rights of the parent folder.
Source code incore/models.py
def copy_rights(self):\n \"\"\"Copy, if possible, the rights of the parent folder.\"\"\"\n if self.parent is not None:\n self.edit_groups.set(self.parent.edit_groups.all())\n self.view_groups.set(self.parent.view_groups.all())\n
"},{"location":"reference/sas/views/#sas.views.SithFile.move_to","title":"move_to(parent)
","text":"Move a file to a new parent. parent
must be a SithFile with the is_folder=True
property. Otherwise, this function doesn't change anything. This is done only at the DB level, so that it's very fast for the user. Indeed, this function doesn't modify SithFiles recursively, so it stays efficient even with top-level folders.
core/models.py
def move_to(self, parent):\n \"\"\"Move a file to a new parent.\n `parent` must be a SithFile with the `is_folder=True` property. Otherwise, this function doesn't change\n anything.\n This is done only at the DB level, so that it's very fast for the user. Indeed, this function doesn't modify\n SithFiles recursively, so it stays efficient even with top-level folders.\n \"\"\"\n if not parent.is_folder:\n return\n self.parent = parent\n self.clean()\n self.save()\n
"},{"location":"reference/sas/views/#sas.views.User","title":"User
","text":" Bases: AbstractBaseUser
Defines the base user class, useable in every app.
This is almost the same as the auth module AbstractUser since it inherits from it, but some fields are required, and the username is generated automatically with the name of the user (see generate_username()).
Added field: nick_name, date_of_birth Required fields: email, first_name, last_name, date_of_birth
"},{"location":"reference/sas/views/#sas.views.User.cached_groups","title":"cached_groups: list[Group]
property
","text":"Get the list of groups this user is in.
The result is cached for the default duration (should be 5 minutes)
Returns: A list of all the groups this user is in.
"},{"location":"reference/sas/views/#sas.views.User.is_in_group","title":"is_in_group(*, pk=None, name=None)
","text":"Check if this user is in the given group. Either a group id or a group name must be provided. If both are passed, only the id will be considered.
The group will be fetched using the given parameter. If no group is found, return False. If a group is found, check if this user is in the latter.
Returns:
Type Descriptionbool
True if the user is the group, else False
Source code incore/models.py
def is_in_group(self, *, pk: int | None = None, name: str | None = None) -> bool:\n \"\"\"Check if this user is in the given group.\n Either a group id or a group name must be provided.\n If both are passed, only the id will be considered.\n\n The group will be fetched using the given parameter.\n If no group is found, return False.\n If a group is found, check if this user is in the latter.\n\n Returns:\n True if the user is the group, else False\n \"\"\"\n if pk is not None:\n group: Optional[Group] = get_group(pk=pk)\n elif name is not None:\n group: Optional[Group] = get_group(name=name)\n else:\n raise ValueError(\"You must either provide the id or the name of the group\")\n if group is None:\n return False\n if group.id == settings.SITH_GROUP_PUBLIC_ID:\n return True\n if group.id == settings.SITH_GROUP_SUBSCRIBERS_ID:\n return self.is_subscribed\n if group.id == settings.SITH_GROUP_OLD_SUBSCRIBERS_ID:\n return self.was_subscribed\n if group.id == settings.SITH_GROUP_ROOT_ID:\n return self.is_root\n if group.is_meta:\n # check if this group is associated with a club\n group.__class__ = MetaGroup\n club = group.associated_club\n if club is None:\n return False\n membership = club.get_membership_for(self)\n if membership is None:\n return False\n if group.name.endswith(settings.SITH_MEMBER_SUFFIX):\n return True\n return membership.role > settings.SITH_MAXIMUM_FREE_ROLE\n return group in self.cached_groups\n
"},{"location":"reference/sas/views/#sas.views.User.age","title":"age()
","text":"Return the age this user has the day the method is called. If the user has not filled his age, return 0.
Source code incore/models.py
@cached_property\ndef age(self) -> int:\n \"\"\"Return the age this user has the day the method is called.\n If the user has not filled his age, return 0.\n \"\"\"\n if self.date_of_birth is None:\n return 0\n today = timezone.now()\n age = today.year - self.date_of_birth.year\n # remove a year if this year's birthday is yet to come\n age -= (today.month, today.day) < (\n self.date_of_birth.month,\n self.date_of_birth.day,\n )\n return age\n
"},{"location":"reference/sas/views/#sas.views.User.get_full_name","title":"get_full_name()
","text":"Returns the first_name plus the last_name, with a space in between.
Source code incore/models.py
def get_full_name(self):\n \"\"\"Returns the first_name plus the last_name, with a space in between.\"\"\"\n full_name = \"%s %s\" % (self.first_name, self.last_name)\n return full_name.strip()\n
"},{"location":"reference/sas/views/#sas.views.User.get_short_name","title":"get_short_name()
","text":"Returns the short name for the user.
Source code incore/models.py
def get_short_name(self):\n \"\"\"Returns the short name for the user.\"\"\"\n if self.nick_name:\n return self.nick_name\n return self.first_name + \" \" + self.last_name\n
"},{"location":"reference/sas/views/#sas.views.User.get_display_name","title":"get_display_name()
","text":"Returns the display name of the user.
A nickname if possible, otherwise, the full name.
Source code incore/models.py
def get_display_name(self) -> str:\n \"\"\"Returns the display name of the user.\n\n A nickname if possible, otherwise, the full name.\n \"\"\"\n if self.nick_name:\n return \"%s (%s)\" % (self.get_full_name(), self.nick_name)\n return self.get_full_name()\n
"},{"location":"reference/sas/views/#sas.views.User.get_age","title":"get_age()
","text":"Returns the age.
Source code incore/models.py
def get_age(self):\n \"\"\"Returns the age.\"\"\"\n today = timezone.now()\n born = self.date_of_birth\n return (\n today.year - born.year - ((today.month, today.day) < (born.month, born.day))\n )\n
"},{"location":"reference/sas/views/#sas.views.User.get_family","title":"get_family(godfathers_depth=4, godchildren_depth=4)
","text":"Get the family of the user, with the given depth.
Parameters:
Name Type Description Defaultgodfathers_depth
NonNegativeInt
The number of generations of godfathers to fetch
4
godchildren_depth
NonNegativeInt
The number of generations of godchildren to fetch
4
Returns:
Type Descriptionset[through]
A list of family relationships in this user's family
Source code incore/models.py
def get_family(\n self,\n godfathers_depth: NonNegativeInt = 4,\n godchildren_depth: NonNegativeInt = 4,\n) -> set[User.godfathers.through]:\n \"\"\"Get the family of the user, with the given depth.\n\n Args:\n godfathers_depth: The number of generations of godfathers to fetch\n godchildren_depth: The number of generations of godchildren to fetch\n\n Returns:\n A list of family relationships in this user's family\n \"\"\"\n res = []\n for depth, key, reverse_key in [\n (godfathers_depth, \"from_user_id\", \"to_user_id\"),\n (godchildren_depth, \"to_user_id\", \"from_user_id\"),\n ]:\n if depth == 0:\n continue\n links = list(User.godfathers.through.objects.filter(**{key: self.id}))\n res.extend(links)\n for _ in range(1, depth): # noqa: F402 we don't care about gettext here\n ids = [getattr(c, reverse_key) for c in links]\n links = list(\n User.godfathers.through.objects.filter(\n **{f\"{key}__in\": ids}\n ).exclude(id__in=[r.id for r in res])\n )\n if not links:\n break\n res.extend(links)\n return set(res)\n
"},{"location":"reference/sas/views/#sas.views.User.email_user","title":"email_user(subject, message, from_email=None, **kwargs)
","text":"Sends an email to this User.
Source code incore/models.py
def email_user(self, subject, message, from_email=None, **kwargs):\n \"\"\"Sends an email to this User.\"\"\"\n if from_email is None:\n from_email = settings.DEFAULT_FROM_EMAIL\n send_mail(subject, message, from_email, [self.email], **kwargs)\n
"},{"location":"reference/sas/views/#sas.views.User.generate_username","title":"generate_username()
","text":"Generates a unique username based on the first and last names.
For example: Guy Carlier gives gcarlier, and gcarlier1 if the first one exists.
Returns:
Type Descriptionstr
The generated username.
Source code incore/models.py
def generate_username(self) -> str:\n \"\"\"Generates a unique username based on the first and last names.\n\n For example: Guy Carlier gives gcarlier, and gcarlier1 if the first one exists.\n\n Returns:\n The generated username.\n \"\"\"\n\n def remove_accents(data):\n return \"\".join(\n x\n for x in unicodedata.normalize(\"NFKD\", data)\n if unicodedata.category(x)[0] == \"L\"\n ).lower()\n\n user_name = (\n remove_accents(self.first_name[0] + self.last_name)\n .encode(\"ascii\", \"ignore\")\n .decode(\"utf-8\")\n )\n un_set = [u.username for u in User.objects.all()]\n if user_name in un_set:\n i = 1\n while user_name + str(i) in un_set:\n i += 1\n user_name += str(i)\n self.username = user_name\n return user_name\n
"},{"location":"reference/sas/views/#sas.views.User.is_owner","title":"is_owner(obj)
","text":"Determine if the object is owned by the user.
Source code incore/models.py
def is_owner(self, obj):\n \"\"\"Determine if the object is owned by the user.\"\"\"\n if hasattr(obj, \"is_owned_by\") and obj.is_owned_by(self):\n return True\n if hasattr(obj, \"owner_group\") and self.is_in_group(pk=obj.owner_group.id):\n return True\n return self.is_root\n
"},{"location":"reference/sas/views/#sas.views.User.can_edit","title":"can_edit(obj)
","text":"Determine if the object can be edited by the user.
Source code incore/models.py
def can_edit(self, obj):\n \"\"\"Determine if the object can be edited by the user.\"\"\"\n if hasattr(obj, \"can_be_edited_by\") and obj.can_be_edited_by(self):\n return True\n if hasattr(obj, \"edit_groups\"):\n for pk in obj.edit_groups.values_list(\"pk\", flat=True):\n if self.is_in_group(pk=pk):\n return True\n if isinstance(obj, User) and obj == self:\n return True\n return self.is_owner(obj)\n
"},{"location":"reference/sas/views/#sas.views.User.can_view","title":"can_view(obj)
","text":"Determine if the object can be viewed by the user.
Source code incore/models.py
def can_view(self, obj):\n \"\"\"Determine if the object can be viewed by the user.\"\"\"\n if hasattr(obj, \"can_be_viewed_by\") and obj.can_be_viewed_by(self):\n return True\n if hasattr(obj, \"view_groups\"):\n for pk in obj.view_groups.values_list(\"pk\", flat=True):\n if self.is_in_group(pk=pk):\n return True\n return self.can_edit(obj)\n
"},{"location":"reference/sas/views/#sas.views.User.clubs_with_rights","title":"clubs_with_rights()
","text":"The list of clubs where the user has rights
Source code incore/models.py
@cached_property\ndef clubs_with_rights(self) -> list[Club]:\n \"\"\"The list of clubs where the user has rights\"\"\"\n memberships = self.memberships.ongoing().board().select_related(\"club\")\n return [m.club for m in memberships]\n
"},{"location":"reference/sas/views/#sas.views.CanEditMixin","title":"CanEditMixin
","text":" Bases: GenericContentPermissionMixinBuilder
Ensure the user has permission to edit this view's object.
Raises:
Type DescriptionPermissionDenied
if the user cannot edit this view's object.
"},{"location":"reference/sas/views/#sas.views.CanViewMixin","title":"CanViewMixin
","text":" Bases: GenericContentPermissionMixinBuilder
Ensure the user has permission to view this view's object.
Raises:
Type DescriptionPermissionDenied
if the user cannot edit this view's object.
"},{"location":"reference/sas/views/#sas.views.FileView","title":"FileView
","text":" Bases: CanViewMixin
, DetailView
, FormMixin
Handle the upload of new files into a folder.
"},{"location":"reference/sas/views/#sas.views.FileView.handle_clipboard","title":"handle_clipboard(request, obj)
staticmethod
","text":"Handle the clipboard in the view.
This method can fail, since it does not catch the exceptions coming from below, allowing proper handling in the calling view. Use this method like this:
FileView.handle_clipboard(request, self.object)\n
request
is usually the self.request obj in your view obj
is the SithFile object you want to put in the clipboard, or where you want to paste the clipboard
core/views/files.py
@staticmethod\ndef handle_clipboard(request, obj):\n \"\"\"Handle the clipboard in the view.\n\n This method can fail, since it does not catch the exceptions coming from\n below, allowing proper handling in the calling view.\n Use this method like this:\n\n FileView.handle_clipboard(request, self.object)\n\n `request` is usually the self.request obj in your view\n `obj` is the SithFile object you want to put in the clipboard, or\n where you want to paste the clipboard\n \"\"\"\n if \"delete\" in request.POST:\n for f_id in request.POST.getlist(\"file_list\"):\n file = SithFile.objects.filter(id=f_id).first()\n if file:\n file.delete()\n if \"clear\" in request.POST:\n request.session[\"clipboard\"] = []\n if \"cut\" in request.POST:\n for f_id_str in request.POST.getlist(\"file_list\"):\n f_id = int(f_id_str)\n if (\n f_id in [c.id for c in obj.children.all()]\n and f_id not in request.session[\"clipboard\"]\n ):\n request.session[\"clipboard\"].append(f_id)\n if \"paste\" in request.POST:\n for f_id in request.session[\"clipboard\"]:\n file = SithFile.objects.filter(id=f_id).first()\n if file:\n file.move_to(obj)\n request.session[\"clipboard\"] = []\n request.session.modified = True\n
"},{"location":"reference/sas/views/#sas.views.AlbumEditForm","title":"AlbumEditForm
","text":" Bases: ModelForm
PictureEditForm
","text":" Bases: ModelForm
PictureModerationRequestForm(*args, user, picture, **kwargs)
","text":" Bases: ModelForm
Form to create a PictureModerationRequest.
The form only manages the reason field, because the author and the picture are set in the view.
Source code insas/forms.py
def __init__(self, *args, user: User, picture: Picture, **kwargs):\n super().__init__(*args, **kwargs)\n self.user = user\n self.picture = picture\n
"},{"location":"reference/sas/views/#sas.views.SASForm","title":"SASForm
","text":" Bases: Form
Album
","text":" Bases: SasFile
NAME_MAX_LENGTH: int = 50
class-attribute
","text":"Maximum length of an album's name.
SithFile have a maximum length of 256 characters. However, this limit is too high for albums. Names longer than 50 characters are harder to read and harder to display on the SAS page.
It is to be noted, though, that this does not add or modify any db behaviour. It's just a constant to be used in views and forms.
"},{"location":"reference/sas/views/#sas.views.Picture","title":"Picture
","text":" Bases: SasFile
SASMainView
","text":" Bases: FormView
PictureView
","text":" Bases: CanViewMixin
, DetailView
AlbumUploadView
","text":" Bases: CanViewMixin
, DetailView
, FormMixin
AlbumView
","text":" Bases: CanViewMixin
, DetailView
, FormMixin
ModerationView
","text":" Bases: TemplateView
PictureEditView
","text":" Bases: CanEditMixin
, UpdateView
PictureAskRemovalView
","text":" Bases: CanViewMixin
, DetailView
, FormView
View to allow users to ask pictures to be removed.
"},{"location":"reference/sas/views/#sas.views.PictureAskRemovalView.get_form_kwargs","title":"get_form_kwargs()
","text":"Add the user and picture to the form kwargs.
Those are required to create the PictureModerationRequest, and aren't part of the form itself (picture is a path parameter, and user is the request user).
Source code insas/views.py
def get_form_kwargs(self) -> dict[str, Any]:\n \"\"\"Add the user and picture to the form kwargs.\n\n Those are required to create the PictureModerationRequest,\n and aren't part of the form itself\n (picture is a path parameter, and user is the request user).\n \"\"\"\n return super().get_form_kwargs() | {\n \"user\": self.request.user,\n \"picture\": self.object,\n }\n
"},{"location":"reference/sas/views/#sas.views.PictureAskRemovalView.get_success_url","title":"get_success_url()
","text":"Return the URL to the album containing the picture.
Source code insas/views.py
def get_success_url(self) -> str:\n \"\"\"Return the URL to the album containing the picture.\"\"\"\n album = Album.objects.filter(pk=self.object.parent_id).first()\n if not album:\n return reverse(\"sas:main\")\n return album.get_absolute_url()\n
"},{"location":"reference/sas/views/#sas.views.AlbumEditView","title":"AlbumEditView
","text":" Bases: CanEditMixin
, UpdateView
send_file(request, file_id, file_class=SithFile, file_attr='file')
","text":"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.
Source code incore/views/files.py
def send_file(\n request: HttpRequest,\n file_id: int,\n file_class: type[SithFile] = SithFile,\n file_attr: str = \"file\",\n) -> HttpResponse:\n \"\"\"Send a protected file, if the user can see it.\n\n In prod, the server won't handle the download itself,\n but set the appropriate headers in the response to make the reverse-proxy\n deal with it.\n In debug mode, the server will directly send the file.\n \"\"\"\n f = get_object_or_404(file_class, id=file_id)\n if not can_view(f, request.user) and not is_logged_in_counter(request):\n raise PermissionDenied\n name = getattr(f, file_attr).name\n\n response = HttpResponse(\n headers={\"Content-Disposition\": f'inline; filename=\"{quote(name)}\"'}\n )\n if not settings.DEBUG:\n # When receiving a response with the Accel-Redirect header,\n # the reverse proxy will automatically handle the file sending.\n # This is really hard to test (thus isn't tested)\n # so please do not mess with this.\n response[\"Content-Type\"] = \"\" # automatically set by nginx\n response[\"X-Accel-Redirect\"] = quote(urljoin(settings.MEDIA_URL, name))\n return response\n\n filepath = settings.MEDIA_ROOT / name\n # check if file exists on disk\n if not filepath.exists():\n raise Http404\n with open(filepath, \"rb\") as filename:\n response.content = FileWrapper(filename)\n response[\"Content-Type\"] = mimetypes.guess_type(filepath)[0]\n response[\"Last-Modified\"] = http_date(f.date.timestamp())\n response[\"Content-Length\"] = filepath.stat().st_size\n return response\n
"},{"location":"reference/sas/views/#sas.views.send_album","title":"send_album(request, album_id)
","text":"Source code in sas/views.py
def send_album(request, album_id):\n return send_file(request, album_id, Album)\n
"},{"location":"reference/sas/views/#sas.views.send_pict","title":"send_pict(request, picture_id)
","text":"Source code in sas/views.py
def send_pict(request, picture_id):\n return send_file(request, picture_id, Picture)\n
"},{"location":"reference/sas/views/#sas.views.send_compressed","title":"send_compressed(request, picture_id)
","text":"Source code in sas/views.py
def send_compressed(request, picture_id):\n return send_file(request, picture_id, Picture, \"compressed\")\n
"},{"location":"reference/sas/views/#sas.views.send_thumb","title":"send_thumb(request, picture_id)
","text":"Source code in sas/views.py
def send_thumb(request, picture_id):\n return send_file(request, picture_id, Picture, \"thumbnail\")\n
"},{"location":"reference/staticfiles/apps/","title":"Apps","text":" Bases: StaticFilesConfig
Application in charge of processing statics files. It replaces the original django staticfiles It integrates scss files and webpack. It makes sure that statics are properly collected and that they are automatically when using the development server.
"},{"location":"reference/staticfiles/apps/#staticfiles.apps.StaticFilesConfig.ignore_patterns","title":"ignore_patterns = IGNORE_PATTERNS
class-attribute
instance-attribute
","text":""},{"location":"reference/staticfiles/apps/#staticfiles.apps.StaticFilesConfig.name","title":"name = 'staticfiles'
class-attribute
instance-attribute
","text":""},{"location":"reference/staticfiles/finders/","title":"Finders","text":""},{"location":"reference/staticfiles/finders/#staticfiles.finders.GENERATED_ROOT","title":"GENERATED_ROOT = Path(__file__).parent.resolve() / 'generated'
module-attribute
","text":""},{"location":"reference/staticfiles/finders/#staticfiles.finders.IGNORE_PATTERNS_WEBPACK","title":"IGNORE_PATTERNS_WEBPACK = ['webpack/*']
module-attribute
","text":""},{"location":"reference/staticfiles/finders/#staticfiles.finders.GeneratedFilesFinder","title":"GeneratedFilesFinder(app_names=None, *args, **kwargs)
","text":" Bases: FileSystemFinder
Find generated and regular static files
Source code instaticfiles/finders.py
def __init__(self, app_names=None, *args, **kwargs):\n super().__init__(*args, **kwargs)\n\n # Add GENERATED_ROOT after adding everything in settings.STATICFILES_DIRS\n self.locations.append((\"\", GENERATED_ROOT))\n generated_storage = FileSystemStorage(location=GENERATED_ROOT)\n generated_storage.prefix = \"\"\n self.storages[GENERATED_ROOT] = generated_storage\n
"},{"location":"reference/staticfiles/processors/","title":"Processors","text":""},{"location":"reference/staticfiles/processors/#staticfiles.processors.GENERATED_ROOT","title":"GENERATED_ROOT = Path(__file__).parent.resolve() / 'generated'
module-attribute
","text":""},{"location":"reference/staticfiles/processors/#staticfiles.processors.Webpack","title":"Webpack
","text":""},{"location":"reference/staticfiles/processors/#staticfiles.processors.Webpack.compile","title":"compile()
staticmethod
","text":"Bundle js files with webpack for production.
Source code instaticfiles/processors.py
@staticmethod\ndef compile():\n \"\"\"Bundle js files with webpack for production.\"\"\"\n process = subprocess.Popen([\"npm\", \"run\", \"compile\"])\n process.wait()\n if process.returncode:\n raise RuntimeError(f\"Webpack failed with returncode {process.returncode}\")\n
"},{"location":"reference/staticfiles/processors/#staticfiles.processors.Webpack.runserver","title":"runserver()
staticmethod
","text":"Bundle js files automatically in background when called in debug mode.
Source code instaticfiles/processors.py
@staticmethod\ndef runserver() -> subprocess.Popen:\n \"\"\"Bundle js files automatically in background when called in debug mode.\"\"\"\n logging.getLogger(\"django\").info(\"Running webpack server\")\n return subprocess.Popen([\"npm\", \"run\", \"serve\"])\n
"},{"location":"reference/staticfiles/processors/#staticfiles.processors.Scss","title":"Scss
","text":""},{"location":"reference/staticfiles/processors/#staticfiles.processors.Scss.compile","title":"compile(files)
staticmethod
","text":"Compile scss files to css files.
Source code instaticfiles/processors.py
@staticmethod\ndef compile(files: CompileArg | Iterable[CompileArg]):\n \"\"\"Compile scss files to css files.\"\"\"\n # Generate files inside the generated folder\n # .css files respects the hierarchy in the static folder it was found\n # This converts arg.absolute -> generated/{arg.relative}.scss\n # Example:\n # app/static/foo.scss -> generated/foo.css\n # app/static/bar/foo.scss -> generated/bar/foo.css\n # custom/location/bar/foo.scss -> generated/bar/foo.css\n if isinstance(files, Scss.CompileArg):\n files = [files]\n\n base_args = {\"output_style\": \"compressed\", \"precision\": settings.SASS_PRECISION}\n\n compiled_files = {\n file.relative.with_suffix(\".css\"): sass.compile(\n filename=str(file.absolute), **base_args\n )\n for file in files\n }\n for file, content in compiled_files.items():\n dest = GENERATED_ROOT / file\n dest.parent.mkdir(exist_ok=True, parents=True)\n dest.write_text(content)\n
"},{"location":"reference/staticfiles/processors/#staticfiles.processors.JS","title":"JS
","text":""},{"location":"reference/staticfiles/processors/#staticfiles.processors.OpenApi","title":"OpenApi
","text":""},{"location":"reference/staticfiles/processors/#staticfiles.processors.OpenApi.compile","title":"compile()
classmethod
","text":"Compile a TS client for the sith API. Only generates it if it changed.
Source code instaticfiles/processors.py
@classmethod\ndef compile(cls):\n \"\"\"Compile a TS client for the sith API. Only generates it if it changed.\"\"\"\n logging.getLogger(\"django\").info(\"Compiling open api typescript client\")\n out = cls.OPENAPI_DIR / \"schema.json\"\n cls.OPENAPI_DIR.mkdir(parents=True, exist_ok=True)\n\n old_hash = \"\"\n if out.exists():\n with open(out, \"rb\") as f:\n old_hash = sha1(f.read()).hexdigest()\n\n schema = api.get_openapi_schema()\n # Remove hash from operationIds\n # This is done for cache invalidation but this is too aggressive\n for path in schema[\"paths\"].values():\n for action, desc in path.items():\n path[action][\"operationId\"] = \"_\".join(\n desc[\"operationId\"].split(\"_\")[:-1]\n )\n schema = str(schema)\n\n if old_hash == sha1(schema.encode(\"utf-8\")).hexdigest():\n logging.getLogger(\"django\").info(\"\u2728 Api did not change, nothing to do \u2728\")\n return\n\n with open(out, \"w\") as f:\n _ = f.write(schema)\n\n subprocess.run([\"npx\", \"openapi-ts\"], check=True)\n
"},{"location":"reference/staticfiles/storage/","title":"Storage","text":""},{"location":"reference/staticfiles/storage/#staticfiles.storage.JS","title":"JS
","text":""},{"location":"reference/staticfiles/storage/#staticfiles.storage.Scss","title":"Scss
","text":""},{"location":"reference/staticfiles/storage/#staticfiles.storage.Scss.compile","title":"compile(files)
staticmethod
","text":"Compile scss files to css files.
Source code instaticfiles/processors.py
@staticmethod\ndef compile(files: CompileArg | Iterable[CompileArg]):\n \"\"\"Compile scss files to css files.\"\"\"\n # Generate files inside the generated folder\n # .css files respects the hierarchy in the static folder it was found\n # This converts arg.absolute -> generated/{arg.relative}.scss\n # Example:\n # app/static/foo.scss -> generated/foo.css\n # app/static/bar/foo.scss -> generated/bar/foo.css\n # custom/location/bar/foo.scss -> generated/bar/foo.css\n if isinstance(files, Scss.CompileArg):\n files = [files]\n\n base_args = {\"output_style\": \"compressed\", \"precision\": settings.SASS_PRECISION}\n\n compiled_files = {\n file.relative.with_suffix(\".css\"): sass.compile(\n filename=str(file.absolute), **base_args\n )\n for file in files\n }\n for file, content in compiled_files.items():\n dest = GENERATED_ROOT / file\n dest.parent.mkdir(exist_ok=True, parents=True)\n dest.write_text(content)\n
"},{"location":"reference/staticfiles/storage/#staticfiles.storage.ManifestPostProcessingStorage","title":"ManifestPostProcessingStorage
","text":" Bases: ManifestStaticFilesStorage
url(name, *, force=False)
","text":"Get the URL for a file, convert .scss calls to .css ones and .ts to .js
Source code instaticfiles/storage.py
def url(self, name: str, *, force: bool = False) -> str:\n \"\"\"Get the URL for a file, convert .scss calls to .css ones and .ts to .js\"\"\"\n # This name swap has to be done here\n # Otherwise, the manifest isn't aware of the file and can't work properly\n path = Path(name)\n if path.suffix == \".scss\":\n # Compile scss files automatically in debug mode\n if settings.DEBUG:\n Scss.compile(\n [\n Scss.CompileArg(absolute=Path(p), relative=Path(name))\n for p in find(name, all=True)\n ]\n )\n name = str(path.with_suffix(\".css\"))\n\n elif path.suffix == \".ts\":\n name = str(path.with_suffix(\".js\"))\n\n return super().url(name, force=force)\n
"},{"location":"reference/subscription/models/","title":"Models","text":""},{"location":"reference/subscription/models/#subscription.models.User","title":"User
","text":" Bases: AbstractBaseUser
Defines the base user class, useable in every app.
This is almost the same as the auth module AbstractUser since it inherits from it, but some fields are required, and the username is generated automatically with the name of the user (see generate_username()).
Added field: nick_name, date_of_birth Required fields: email, first_name, last_name, date_of_birth
"},{"location":"reference/subscription/models/#subscription.models.User.cached_groups","title":"cached_groups: list[Group]
property
","text":"Get the list of groups this user is in.
The result is cached for the default duration (should be 5 minutes)
Returns: A list of all the groups this user is in.
"},{"location":"reference/subscription/models/#subscription.models.User.is_in_group","title":"is_in_group(*, pk=None, name=None)
","text":"Check if this user is in the given group. Either a group id or a group name must be provided. If both are passed, only the id will be considered.
The group will be fetched using the given parameter. If no group is found, return False. If a group is found, check if this user is in the latter.
Returns:
Type Descriptionbool
True if the user is the group, else False
Source code incore/models.py
def is_in_group(self, *, pk: int | None = None, name: str | None = None) -> bool:\n \"\"\"Check if this user is in the given group.\n Either a group id or a group name must be provided.\n If both are passed, only the id will be considered.\n\n The group will be fetched using the given parameter.\n If no group is found, return False.\n If a group is found, check if this user is in the latter.\n\n Returns:\n True if the user is the group, else False\n \"\"\"\n if pk is not None:\n group: Optional[Group] = get_group(pk=pk)\n elif name is not None:\n group: Optional[Group] = get_group(name=name)\n else:\n raise ValueError(\"You must either provide the id or the name of the group\")\n if group is None:\n return False\n if group.id == settings.SITH_GROUP_PUBLIC_ID:\n return True\n if group.id == settings.SITH_GROUP_SUBSCRIBERS_ID:\n return self.is_subscribed\n if group.id == settings.SITH_GROUP_OLD_SUBSCRIBERS_ID:\n return self.was_subscribed\n if group.id == settings.SITH_GROUP_ROOT_ID:\n return self.is_root\n if group.is_meta:\n # check if this group is associated with a club\n group.__class__ = MetaGroup\n club = group.associated_club\n if club is None:\n return False\n membership = club.get_membership_for(self)\n if membership is None:\n return False\n if group.name.endswith(settings.SITH_MEMBER_SUFFIX):\n return True\n return membership.role > settings.SITH_MAXIMUM_FREE_ROLE\n return group in self.cached_groups\n
"},{"location":"reference/subscription/models/#subscription.models.User.age","title":"age()
","text":"Return the age this user has the day the method is called. If the user has not filled his age, return 0.
Source code incore/models.py
@cached_property\ndef age(self) -> int:\n \"\"\"Return the age this user has the day the method is called.\n If the user has not filled his age, return 0.\n \"\"\"\n if self.date_of_birth is None:\n return 0\n today = timezone.now()\n age = today.year - self.date_of_birth.year\n # remove a year if this year's birthday is yet to come\n age -= (today.month, today.day) < (\n self.date_of_birth.month,\n self.date_of_birth.day,\n )\n return age\n
"},{"location":"reference/subscription/models/#subscription.models.User.get_full_name","title":"get_full_name()
","text":"Returns the first_name plus the last_name, with a space in between.
Source code incore/models.py
def get_full_name(self):\n \"\"\"Returns the first_name plus the last_name, with a space in between.\"\"\"\n full_name = \"%s %s\" % (self.first_name, self.last_name)\n return full_name.strip()\n
"},{"location":"reference/subscription/models/#subscription.models.User.get_short_name","title":"get_short_name()
","text":"Returns the short name for the user.
Source code incore/models.py
def get_short_name(self):\n \"\"\"Returns the short name for the user.\"\"\"\n if self.nick_name:\n return self.nick_name\n return self.first_name + \" \" + self.last_name\n
"},{"location":"reference/subscription/models/#subscription.models.User.get_display_name","title":"get_display_name()
","text":"Returns the display name of the user.
A nickname if possible, otherwise, the full name.
Source code incore/models.py
def get_display_name(self) -> str:\n \"\"\"Returns the display name of the user.\n\n A nickname if possible, otherwise, the full name.\n \"\"\"\n if self.nick_name:\n return \"%s (%s)\" % (self.get_full_name(), self.nick_name)\n return self.get_full_name()\n
"},{"location":"reference/subscription/models/#subscription.models.User.get_age","title":"get_age()
","text":"Returns the age.
Source code incore/models.py
def get_age(self):\n \"\"\"Returns the age.\"\"\"\n today = timezone.now()\n born = self.date_of_birth\n return (\n today.year - born.year - ((today.month, today.day) < (born.month, born.day))\n )\n
"},{"location":"reference/subscription/models/#subscription.models.User.get_family","title":"get_family(godfathers_depth=4, godchildren_depth=4)
","text":"Get the family of the user, with the given depth.
Parameters:
Name Type Description Defaultgodfathers_depth
NonNegativeInt
The number of generations of godfathers to fetch
4
godchildren_depth
NonNegativeInt
The number of generations of godchildren to fetch
4
Returns:
Type Descriptionset[through]
A list of family relationships in this user's family
Source code incore/models.py
def get_family(\n self,\n godfathers_depth: NonNegativeInt = 4,\n godchildren_depth: NonNegativeInt = 4,\n) -> set[User.godfathers.through]:\n \"\"\"Get the family of the user, with the given depth.\n\n Args:\n godfathers_depth: The number of generations of godfathers to fetch\n godchildren_depth: The number of generations of godchildren to fetch\n\n Returns:\n A list of family relationships in this user's family\n \"\"\"\n res = []\n for depth, key, reverse_key in [\n (godfathers_depth, \"from_user_id\", \"to_user_id\"),\n (godchildren_depth, \"to_user_id\", \"from_user_id\"),\n ]:\n if depth == 0:\n continue\n links = list(User.godfathers.through.objects.filter(**{key: self.id}))\n res.extend(links)\n for _ in range(1, depth): # noqa: F402 we don't care about gettext here\n ids = [getattr(c, reverse_key) for c in links]\n links = list(\n User.godfathers.through.objects.filter(\n **{f\"{key}__in\": ids}\n ).exclude(id__in=[r.id for r in res])\n )\n if not links:\n break\n res.extend(links)\n return set(res)\n
"},{"location":"reference/subscription/models/#subscription.models.User.email_user","title":"email_user(subject, message, from_email=None, **kwargs)
","text":"Sends an email to this User.
Source code incore/models.py
def email_user(self, subject, message, from_email=None, **kwargs):\n \"\"\"Sends an email to this User.\"\"\"\n if from_email is None:\n from_email = settings.DEFAULT_FROM_EMAIL\n send_mail(subject, message, from_email, [self.email], **kwargs)\n
"},{"location":"reference/subscription/models/#subscription.models.User.generate_username","title":"generate_username()
","text":"Generates a unique username based on the first and last names.
For example: Guy Carlier gives gcarlier, and gcarlier1 if the first one exists.
Returns:
Type Descriptionstr
The generated username.
Source code incore/models.py
def generate_username(self) -> str:\n \"\"\"Generates a unique username based on the first and last names.\n\n For example: Guy Carlier gives gcarlier, and gcarlier1 if the first one exists.\n\n Returns:\n The generated username.\n \"\"\"\n\n def remove_accents(data):\n return \"\".join(\n x\n for x in unicodedata.normalize(\"NFKD\", data)\n if unicodedata.category(x)[0] == \"L\"\n ).lower()\n\n user_name = (\n remove_accents(self.first_name[0] + self.last_name)\n .encode(\"ascii\", \"ignore\")\n .decode(\"utf-8\")\n )\n un_set = [u.username for u in User.objects.all()]\n if user_name in un_set:\n i = 1\n while user_name + str(i) in un_set:\n i += 1\n user_name += str(i)\n self.username = user_name\n return user_name\n
"},{"location":"reference/subscription/models/#subscription.models.User.is_owner","title":"is_owner(obj)
","text":"Determine if the object is owned by the user.
Source code incore/models.py
def is_owner(self, obj):\n \"\"\"Determine if the object is owned by the user.\"\"\"\n if hasattr(obj, \"is_owned_by\") and obj.is_owned_by(self):\n return True\n if hasattr(obj, \"owner_group\") and self.is_in_group(pk=obj.owner_group.id):\n return True\n return self.is_root\n
"},{"location":"reference/subscription/models/#subscription.models.User.can_edit","title":"can_edit(obj)
","text":"Determine if the object can be edited by the user.
Source code incore/models.py
def can_edit(self, obj):\n \"\"\"Determine if the object can be edited by the user.\"\"\"\n if hasattr(obj, \"can_be_edited_by\") and obj.can_be_edited_by(self):\n return True\n if hasattr(obj, \"edit_groups\"):\n for pk in obj.edit_groups.values_list(\"pk\", flat=True):\n if self.is_in_group(pk=pk):\n return True\n if isinstance(obj, User) and obj == self:\n return True\n return self.is_owner(obj)\n
"},{"location":"reference/subscription/models/#subscription.models.User.can_view","title":"can_view(obj)
","text":"Determine if the object can be viewed by the user.
Source code incore/models.py
def can_view(self, obj):\n \"\"\"Determine if the object can be viewed by the user.\"\"\"\n if hasattr(obj, \"can_be_viewed_by\") and obj.can_be_viewed_by(self):\n return True\n if hasattr(obj, \"view_groups\"):\n for pk in obj.view_groups.values_list(\"pk\", flat=True):\n if self.is_in_group(pk=pk):\n return True\n return self.can_edit(obj)\n
"},{"location":"reference/subscription/models/#subscription.models.User.clubs_with_rights","title":"clubs_with_rights()
","text":"The list of clubs where the user has rights
Source code incore/models.py
@cached_property\ndef clubs_with_rights(self) -> list[Club]:\n \"\"\"The list of clubs where the user has rights\"\"\"\n memberships = self.memberships.ongoing().board().select_related(\"club\")\n return [m.club for m in memberships]\n
"},{"location":"reference/subscription/models/#subscription.models.Subscription","title":"Subscription
","text":" Bases: Model
compute_start(d=None, duration=1, user=None)
staticmethod
","text":"Computes the start date of the subscription.
The computation is done with respect to the given date (default is today) and the start date given in settings.SITH_SEMESTER_START_AUTUMN. It takes the nearest past start date. Exemples: with SITH_SEMESTER_START_AUTUMN = (8, 15) Today -> Start date 2015-03-17 -> 2015-02-15 2015-01-11 -> 2014-08-15.
Source code insubscription/models.py
@staticmethod\ndef compute_start(\n d: date | None = None, duration: int = 1, user: User | None = None\n) -> date:\n \"\"\"Computes the start date of the subscription.\n\n The computation is done with respect to the given date (default is today)\n and the start date given in settings.SITH_SEMESTER_START_AUTUMN.\n It takes the nearest past start date.\n Exemples: with SITH_SEMESTER_START_AUTUMN = (8, 15)\n Today -> Start date\n 2015-03-17 -> 2015-02-15\n 2015-01-11 -> 2014-08-15.\n \"\"\"\n if not d:\n d = date.today()\n if user is not None and user.subscriptions.exists():\n last = user.subscriptions.last()\n if last.is_valid_now():\n d = last.subscription_end\n if duration <= 2: # Sliding subscriptions for 1 or 2 semesters\n return d\n return get_start_of_semester(d)\n
"},{"location":"reference/subscription/models/#subscription.models.Subscription.compute_end","title":"compute_end(duration, start=None, user=None)
staticmethod
","text":"Compute the end date of the subscription.
Parameters:
Name Type Description Defaultduration
int
the duration of the subscription, in semester (for example, 2 => 2 semesters => 1 year)
requiredstart
date | None
The start date of the subscription
None
user
User | None
the user which is (or will be) subscribed
None
Exemples Start - Duration -> End date 2015-09-18 - 1 -> 2016-03-18 2015-09-18 - 2 -> 2016-09-18 2015-09-18 - 3 -> 2017-03-18 2015-09-18 - 4 -> 2017-09-18.
Source code insubscription/models.py
@staticmethod\ndef compute_end(\n duration: int, start: date | None = None, user: User | None = None\n) -> date:\n \"\"\"Compute the end date of the subscription.\n\n Args:\n duration:\n the duration of the subscription, in semester\n (for example, 2 => 2 semesters => 1 year)\n start: The start date of the subscription\n user: the user which is (or will be) subscribed\n\n Exemples:\n Start - Duration -> End date\n 2015-09-18 - 1 -> 2016-03-18\n 2015-09-18 - 2 -> 2016-09-18\n 2015-09-18 - 3 -> 2017-03-18\n 2015-09-18 - 4 -> 2017-09-18.\n \"\"\"\n if start is None:\n start = Subscription.compute_start(duration=duration, user=user)\n\n return start + relativedelta(\n months=round(6 * duration),\n days=math.ceil((6 * duration - round(6 * duration)) * 30),\n )\n
"},{"location":"reference/subscription/models/#subscription.models.get_start_of_semester","title":"get_start_of_semester(today=None)
","text":"Return the date of the start of the semester of the given date. If no date is given, return the start date of the current semester.
The current semester is computed as follows:
Parameters:
Name Type Description Defaulttoday
Optional[date]
the date to use to compute the semester. If None, use today's date.
None
Returns:
Type Descriptiondate
the date of the start of the semester
Source code incore/utils.py
def get_start_of_semester(today: Optional[date] = None) -> date:\n \"\"\"Return the date of the start of the semester of the given date.\n If no date is given, return the start date of the current semester.\n\n The current semester is computed as follows:\n\n - If the date is between 15/08 and 31/12 => Autumn semester.\n - If the date is between 01/01 and 15/02 => Autumn semester of the previous year.\n - If the date is between 15/02 and 15/08 => Spring semester\n\n Args:\n today: the date to use to compute the semester. If None, use today's date.\n\n Returns:\n the date of the start of the semester\n \"\"\"\n if today is None:\n today = localdate()\n\n autumn = date(today.year, *settings.SITH_SEMESTER_START_AUTUMN)\n spring = date(today.year, *settings.SITH_SEMESTER_START_SPRING)\n\n if today >= autumn: # between 15/08 (included) and 31/12 -> autumn semester\n return autumn\n if today >= spring: # between 15/02 (included) and 15/08 -> spring semester\n return spring\n # between 01/01 and 15/02 -> autumn semester of the previous year\n return autumn.replace(year=autumn.year - 1)\n
"},{"location":"reference/subscription/models/#subscription.models.validate_type","title":"validate_type(value)
","text":"Source code in subscription/models.py
def validate_type(value):\n if value not in settings.SITH_SUBSCRIPTIONS:\n raise ValidationError(_(\"Bad subscription type\"))\n
"},{"location":"reference/subscription/models/#subscription.models.validate_payment","title":"validate_payment(value)
","text":"Source code in subscription/models.py
def validate_payment(value):\n if value not in settings.SITH_SUBSCRIPTION_PAYMENT_METHOD:\n raise ValidationError(_(\"Bad payment method\"))\n
"},{"location":"reference/subscription/views/","title":"Views","text":""},{"location":"reference/subscription/views/#subscription.views.User","title":"User
","text":" Bases: AbstractBaseUser
Defines the base user class, useable in every app.
This is almost the same as the auth module AbstractUser since it inherits from it, but some fields are required, and the username is generated automatically with the name of the user (see generate_username()).
Added field: nick_name, date_of_birth Required fields: email, first_name, last_name, date_of_birth
"},{"location":"reference/subscription/views/#subscription.views.User.cached_groups","title":"cached_groups: list[Group]
property
","text":"Get the list of groups this user is in.
The result is cached for the default duration (should be 5 minutes)
Returns: A list of all the groups this user is in.
"},{"location":"reference/subscription/views/#subscription.views.User.is_in_group","title":"is_in_group(*, pk=None, name=None)
","text":"Check if this user is in the given group. Either a group id or a group name must be provided. If both are passed, only the id will be considered.
The group will be fetched using the given parameter. If no group is found, return False. If a group is found, check if this user is in the latter.
Returns:
Type Descriptionbool
True if the user is the group, else False
Source code incore/models.py
def is_in_group(self, *, pk: int | None = None, name: str | None = None) -> bool:\n \"\"\"Check if this user is in the given group.\n Either a group id or a group name must be provided.\n If both are passed, only the id will be considered.\n\n The group will be fetched using the given parameter.\n If no group is found, return False.\n If a group is found, check if this user is in the latter.\n\n Returns:\n True if the user is the group, else False\n \"\"\"\n if pk is not None:\n group: Optional[Group] = get_group(pk=pk)\n elif name is not None:\n group: Optional[Group] = get_group(name=name)\n else:\n raise ValueError(\"You must either provide the id or the name of the group\")\n if group is None:\n return False\n if group.id == settings.SITH_GROUP_PUBLIC_ID:\n return True\n if group.id == settings.SITH_GROUP_SUBSCRIBERS_ID:\n return self.is_subscribed\n if group.id == settings.SITH_GROUP_OLD_SUBSCRIBERS_ID:\n return self.was_subscribed\n if group.id == settings.SITH_GROUP_ROOT_ID:\n return self.is_root\n if group.is_meta:\n # check if this group is associated with a club\n group.__class__ = MetaGroup\n club = group.associated_club\n if club is None:\n return False\n membership = club.get_membership_for(self)\n if membership is None:\n return False\n if group.name.endswith(settings.SITH_MEMBER_SUFFIX):\n return True\n return membership.role > settings.SITH_MAXIMUM_FREE_ROLE\n return group in self.cached_groups\n
"},{"location":"reference/subscription/views/#subscription.views.User.age","title":"age()
","text":"Return the age this user has the day the method is called. If the user has not filled his age, return 0.
Source code incore/models.py
@cached_property\ndef age(self) -> int:\n \"\"\"Return the age this user has the day the method is called.\n If the user has not filled his age, return 0.\n \"\"\"\n if self.date_of_birth is None:\n return 0\n today = timezone.now()\n age = today.year - self.date_of_birth.year\n # remove a year if this year's birthday is yet to come\n age -= (today.month, today.day) < (\n self.date_of_birth.month,\n self.date_of_birth.day,\n )\n return age\n
"},{"location":"reference/subscription/views/#subscription.views.User.get_full_name","title":"get_full_name()
","text":"Returns the first_name plus the last_name, with a space in between.
Source code incore/models.py
def get_full_name(self):\n \"\"\"Returns the first_name plus the last_name, with a space in between.\"\"\"\n full_name = \"%s %s\" % (self.first_name, self.last_name)\n return full_name.strip()\n
"},{"location":"reference/subscription/views/#subscription.views.User.get_short_name","title":"get_short_name()
","text":"Returns the short name for the user.
Source code incore/models.py
def get_short_name(self):\n \"\"\"Returns the short name for the user.\"\"\"\n if self.nick_name:\n return self.nick_name\n return self.first_name + \" \" + self.last_name\n
"},{"location":"reference/subscription/views/#subscription.views.User.get_display_name","title":"get_display_name()
","text":"Returns the display name of the user.
A nickname if possible, otherwise, the full name.
Source code incore/models.py
def get_display_name(self) -> str:\n \"\"\"Returns the display name of the user.\n\n A nickname if possible, otherwise, the full name.\n \"\"\"\n if self.nick_name:\n return \"%s (%s)\" % (self.get_full_name(), self.nick_name)\n return self.get_full_name()\n
"},{"location":"reference/subscription/views/#subscription.views.User.get_age","title":"get_age()
","text":"Returns the age.
Source code incore/models.py
def get_age(self):\n \"\"\"Returns the age.\"\"\"\n today = timezone.now()\n born = self.date_of_birth\n return (\n today.year - born.year - ((today.month, today.day) < (born.month, born.day))\n )\n
"},{"location":"reference/subscription/views/#subscription.views.User.get_family","title":"get_family(godfathers_depth=4, godchildren_depth=4)
","text":"Get the family of the user, with the given depth.
Parameters:
Name Type Description Defaultgodfathers_depth
NonNegativeInt
The number of generations of godfathers to fetch
4
godchildren_depth
NonNegativeInt
The number of generations of godchildren to fetch
4
Returns:
Type Descriptionset[through]
A list of family relationships in this user's family
Source code incore/models.py
def get_family(\n self,\n godfathers_depth: NonNegativeInt = 4,\n godchildren_depth: NonNegativeInt = 4,\n) -> set[User.godfathers.through]:\n \"\"\"Get the family of the user, with the given depth.\n\n Args:\n godfathers_depth: The number of generations of godfathers to fetch\n godchildren_depth: The number of generations of godchildren to fetch\n\n Returns:\n A list of family relationships in this user's family\n \"\"\"\n res = []\n for depth, key, reverse_key in [\n (godfathers_depth, \"from_user_id\", \"to_user_id\"),\n (godchildren_depth, \"to_user_id\", \"from_user_id\"),\n ]:\n if depth == 0:\n continue\n links = list(User.godfathers.through.objects.filter(**{key: self.id}))\n res.extend(links)\n for _ in range(1, depth): # noqa: F402 we don't care about gettext here\n ids = [getattr(c, reverse_key) for c in links]\n links = list(\n User.godfathers.through.objects.filter(\n **{f\"{key}__in\": ids}\n ).exclude(id__in=[r.id for r in res])\n )\n if not links:\n break\n res.extend(links)\n return set(res)\n
"},{"location":"reference/subscription/views/#subscription.views.User.email_user","title":"email_user(subject, message, from_email=None, **kwargs)
","text":"Sends an email to this User.
Source code incore/models.py
def email_user(self, subject, message, from_email=None, **kwargs):\n \"\"\"Sends an email to this User.\"\"\"\n if from_email is None:\n from_email = settings.DEFAULT_FROM_EMAIL\n send_mail(subject, message, from_email, [self.email], **kwargs)\n
"},{"location":"reference/subscription/views/#subscription.views.User.generate_username","title":"generate_username()
","text":"Generates a unique username based on the first and last names.
For example: Guy Carlier gives gcarlier, and gcarlier1 if the first one exists.
Returns:
Type Descriptionstr
The generated username.
Source code incore/models.py
def generate_username(self) -> str:\n \"\"\"Generates a unique username based on the first and last names.\n\n For example: Guy Carlier gives gcarlier, and gcarlier1 if the first one exists.\n\n Returns:\n The generated username.\n \"\"\"\n\n def remove_accents(data):\n return \"\".join(\n x\n for x in unicodedata.normalize(\"NFKD\", data)\n if unicodedata.category(x)[0] == \"L\"\n ).lower()\n\n user_name = (\n remove_accents(self.first_name[0] + self.last_name)\n .encode(\"ascii\", \"ignore\")\n .decode(\"utf-8\")\n )\n un_set = [u.username for u in User.objects.all()]\n if user_name in un_set:\n i = 1\n while user_name + str(i) in un_set:\n i += 1\n user_name += str(i)\n self.username = user_name\n return user_name\n
"},{"location":"reference/subscription/views/#subscription.views.User.is_owner","title":"is_owner(obj)
","text":"Determine if the object is owned by the user.
Source code incore/models.py
def is_owner(self, obj):\n \"\"\"Determine if the object is owned by the user.\"\"\"\n if hasattr(obj, \"is_owned_by\") and obj.is_owned_by(self):\n return True\n if hasattr(obj, \"owner_group\") and self.is_in_group(pk=obj.owner_group.id):\n return True\n return self.is_root\n
"},{"location":"reference/subscription/views/#subscription.views.User.can_edit","title":"can_edit(obj)
","text":"Determine if the object can be edited by the user.
Source code incore/models.py
def can_edit(self, obj):\n \"\"\"Determine if the object can be edited by the user.\"\"\"\n if hasattr(obj, \"can_be_edited_by\") and obj.can_be_edited_by(self):\n return True\n if hasattr(obj, \"edit_groups\"):\n for pk in obj.edit_groups.values_list(\"pk\", flat=True):\n if self.is_in_group(pk=pk):\n return True\n if isinstance(obj, User) and obj == self:\n return True\n return self.is_owner(obj)\n
"},{"location":"reference/subscription/views/#subscription.views.User.can_view","title":"can_view(obj)
","text":"Determine if the object can be viewed by the user.
Source code incore/models.py
def can_view(self, obj):\n \"\"\"Determine if the object can be viewed by the user.\"\"\"\n if hasattr(obj, \"can_be_viewed_by\") and obj.can_be_viewed_by(self):\n return True\n if hasattr(obj, \"view_groups\"):\n for pk in obj.view_groups.values_list(\"pk\", flat=True):\n if self.is_in_group(pk=pk):\n return True\n return self.can_edit(obj)\n
"},{"location":"reference/subscription/views/#subscription.views.User.clubs_with_rights","title":"clubs_with_rights()
","text":"The list of clubs where the user has rights
Source code incore/models.py
@cached_property\ndef clubs_with_rights(self) -> list[Club]:\n \"\"\"The list of clubs where the user has rights\"\"\"\n memberships = self.memberships.ongoing().board().select_related(\"club\")\n return [m.club for m in memberships]\n
"},{"location":"reference/subscription/views/#subscription.views.Subscription","title":"Subscription
","text":" Bases: Model
compute_start(d=None, duration=1, user=None)
staticmethod
","text":"Computes the start date of the subscription.
The computation is done with respect to the given date (default is today) and the start date given in settings.SITH_SEMESTER_START_AUTUMN. It takes the nearest past start date. Exemples: with SITH_SEMESTER_START_AUTUMN = (8, 15) Today -> Start date 2015-03-17 -> 2015-02-15 2015-01-11 -> 2014-08-15.
Source code insubscription/models.py
@staticmethod\ndef compute_start(\n d: date | None = None, duration: int = 1, user: User | None = None\n) -> date:\n \"\"\"Computes the start date of the subscription.\n\n The computation is done with respect to the given date (default is today)\n and the start date given in settings.SITH_SEMESTER_START_AUTUMN.\n It takes the nearest past start date.\n Exemples: with SITH_SEMESTER_START_AUTUMN = (8, 15)\n Today -> Start date\n 2015-03-17 -> 2015-02-15\n 2015-01-11 -> 2014-08-15.\n \"\"\"\n if not d:\n d = date.today()\n if user is not None and user.subscriptions.exists():\n last = user.subscriptions.last()\n if last.is_valid_now():\n d = last.subscription_end\n if duration <= 2: # Sliding subscriptions for 1 or 2 semesters\n return d\n return get_start_of_semester(d)\n
"},{"location":"reference/subscription/views/#subscription.views.Subscription.compute_end","title":"compute_end(duration, start=None, user=None)
staticmethod
","text":"Compute the end date of the subscription.
Parameters:
Name Type Description Defaultduration
int
the duration of the subscription, in semester (for example, 2 => 2 semesters => 1 year)
requiredstart
date | None
The start date of the subscription
None
user
User | None
the user which is (or will be) subscribed
None
Exemples Start - Duration -> End date 2015-09-18 - 1 -> 2016-03-18 2015-09-18 - 2 -> 2016-09-18 2015-09-18 - 3 -> 2017-03-18 2015-09-18 - 4 -> 2017-09-18.
Source code insubscription/models.py
@staticmethod\ndef compute_end(\n duration: int, start: date | None = None, user: User | None = None\n) -> date:\n \"\"\"Compute the end date of the subscription.\n\n Args:\n duration:\n the duration of the subscription, in semester\n (for example, 2 => 2 semesters => 1 year)\n start: The start date of the subscription\n user: the user which is (or will be) subscribed\n\n Exemples:\n Start - Duration -> End date\n 2015-09-18 - 1 -> 2016-03-18\n 2015-09-18 - 2 -> 2016-09-18\n 2015-09-18 - 3 -> 2017-03-18\n 2015-09-18 - 4 -> 2017-09-18.\n \"\"\"\n if start is None:\n start = Subscription.compute_start(duration=duration, user=user)\n\n return start + relativedelta(\n months=round(6 * duration),\n days=math.ceil((6 * duration - round(6 * duration)) * 30),\n )\n
"},{"location":"reference/subscription/views/#subscription.views.SelectionDateForm","title":"SelectionDateForm(*args, **kwargs)
","text":" Bases: Form
subscription/views.py
def __init__(self, *args, **kwargs):\n super().__init__(*args, **kwargs)\n self.fields[\"start_date\"] = forms.DateTimeField(\n label=_(\"Start date\"), widget=SelectDateTime, required=True\n )\n self.fields[\"end_date\"] = forms.DateTimeField(\n label=_(\"End date\"), widget=SelectDateTime, required=True\n )\n
"},{"location":"reference/subscription/views/#subscription.views.SubscriptionForm","title":"SubscriptionForm(*args, **kwargs)
","text":" Bases: ModelForm
subscription/views.py
def __init__(self, *args, **kwargs):\n super().__init__(*args, **kwargs)\n self.fields[\"member\"].required = False\n self.fields |= forms.fields_for_model(\n User,\n fields=[\"first_name\", \"last_name\", \"email\", \"date_of_birth\"],\n widgets={\"date_of_birth\": SelectDate},\n )\n
"},{"location":"reference/subscription/views/#subscription.views.NewSubscription","title":"NewSubscription
","text":" Bases: CreateView
SubscriptionsStatsView
","text":" Bases: FormView
Club
","text":" Bases: Model
The Club class, made as a tree to allow nice tidy organization.
"},{"location":"reference/trombi/models/#trombi.models.Club.check_loop","title":"check_loop()
","text":"Raise a validation error when a loop is found within the parent list.
Source code inclub/models.py
def check_loop(self):\n \"\"\"Raise a validation error when a loop is found within the parent list.\"\"\"\n objs = []\n cur = self\n while cur.parent is not None:\n if cur in objs:\n raise ValidationError(_(\"You can not make loops in clubs\"))\n objs.append(cur)\n cur = cur.parent\n
"},{"location":"reference/trombi/models/#trombi.models.Club.is_owned_by","title":"is_owned_by(user)
","text":"Method to see if that object can be super edited by the given user.
Source code inclub/models.py
def is_owned_by(self, user):\n \"\"\"Method to see if that object can be super edited by the given user.\"\"\"\n if user.is_anonymous:\n return False\n return user.is_board_member\n
"},{"location":"reference/trombi/models/#trombi.models.Club.can_be_edited_by","title":"can_be_edited_by(user)
","text":"Method to see if that object can be edited by the given user.
Source code inclub/models.py
def can_be_edited_by(self, user):\n \"\"\"Method to see if that object can be edited by the given user.\"\"\"\n return self.has_rights_in_club(user)\n
"},{"location":"reference/trombi/models/#trombi.models.Club.can_be_viewed_by","title":"can_be_viewed_by(user)
","text":"Method to see if that object can be seen by the given user.
Source code inclub/models.py
def can_be_viewed_by(self, user):\n \"\"\"Method to see if that object can be seen by the given user.\"\"\"\n sub = User.objects.filter(pk=user.pk).first()\n if sub is None:\n return False\n return sub.was_subscribed\n
"},{"location":"reference/trombi/models/#trombi.models.Club.get_membership_for","title":"get_membership_for(user)
","text":"Return the current membership the given user.
NoteThe result is cached.
Source code inclub/models.py
def get_membership_for(self, user: User) -> Membership | None:\n \"\"\"Return the current membership the given user.\n\n Note:\n The result is cached.\n \"\"\"\n if user.is_anonymous:\n return None\n membership = cache.get(f\"membership_{self.id}_{user.id}\")\n if membership == \"not_member\":\n return None\n if membership is None:\n membership = self.members.filter(user=user, end_date=None).first()\n if membership is None:\n cache.set(f\"membership_{self.id}_{user.id}\", \"not_member\")\n else:\n cache.set(f\"membership_{self.id}_{user.id}\", membership)\n return membership\n
"},{"location":"reference/trombi/models/#trombi.models.User","title":"User
","text":" Bases: AbstractBaseUser
Defines the base user class, useable in every app.
This is almost the same as the auth module AbstractUser since it inherits from it, but some fields are required, and the username is generated automatically with the name of the user (see generate_username()).
Added field: nick_name, date_of_birth Required fields: email, first_name, last_name, date_of_birth
"},{"location":"reference/trombi/models/#trombi.models.User.cached_groups","title":"cached_groups: list[Group]
property
","text":"Get the list of groups this user is in.
The result is cached for the default duration (should be 5 minutes)
Returns: A list of all the groups this user is in.
"},{"location":"reference/trombi/models/#trombi.models.User.is_in_group","title":"is_in_group(*, pk=None, name=None)
","text":"Check if this user is in the given group. Either a group id or a group name must be provided. If both are passed, only the id will be considered.
The group will be fetched using the given parameter. If no group is found, return False. If a group is found, check if this user is in the latter.
Returns:
Type Descriptionbool
True if the user is the group, else False
Source code incore/models.py
def is_in_group(self, *, pk: int | None = None, name: str | None = None) -> bool:\n \"\"\"Check if this user is in the given group.\n Either a group id or a group name must be provided.\n If both are passed, only the id will be considered.\n\n The group will be fetched using the given parameter.\n If no group is found, return False.\n If a group is found, check if this user is in the latter.\n\n Returns:\n True if the user is the group, else False\n \"\"\"\n if pk is not None:\n group: Optional[Group] = get_group(pk=pk)\n elif name is not None:\n group: Optional[Group] = get_group(name=name)\n else:\n raise ValueError(\"You must either provide the id or the name of the group\")\n if group is None:\n return False\n if group.id == settings.SITH_GROUP_PUBLIC_ID:\n return True\n if group.id == settings.SITH_GROUP_SUBSCRIBERS_ID:\n return self.is_subscribed\n if group.id == settings.SITH_GROUP_OLD_SUBSCRIBERS_ID:\n return self.was_subscribed\n if group.id == settings.SITH_GROUP_ROOT_ID:\n return self.is_root\n if group.is_meta:\n # check if this group is associated with a club\n group.__class__ = MetaGroup\n club = group.associated_club\n if club is None:\n return False\n membership = club.get_membership_for(self)\n if membership is None:\n return False\n if group.name.endswith(settings.SITH_MEMBER_SUFFIX):\n return True\n return membership.role > settings.SITH_MAXIMUM_FREE_ROLE\n return group in self.cached_groups\n
"},{"location":"reference/trombi/models/#trombi.models.User.age","title":"age()
","text":"Return the age this user has the day the method is called. If the user has not filled his age, return 0.
Source code incore/models.py
@cached_property\ndef age(self) -> int:\n \"\"\"Return the age this user has the day the method is called.\n If the user has not filled his age, return 0.\n \"\"\"\n if self.date_of_birth is None:\n return 0\n today = timezone.now()\n age = today.year - self.date_of_birth.year\n # remove a year if this year's birthday is yet to come\n age -= (today.month, today.day) < (\n self.date_of_birth.month,\n self.date_of_birth.day,\n )\n return age\n
"},{"location":"reference/trombi/models/#trombi.models.User.get_full_name","title":"get_full_name()
","text":"Returns the first_name plus the last_name, with a space in between.
Source code incore/models.py
def get_full_name(self):\n \"\"\"Returns the first_name plus the last_name, with a space in between.\"\"\"\n full_name = \"%s %s\" % (self.first_name, self.last_name)\n return full_name.strip()\n
"},{"location":"reference/trombi/models/#trombi.models.User.get_short_name","title":"get_short_name()
","text":"Returns the short name for the user.
Source code incore/models.py
def get_short_name(self):\n \"\"\"Returns the short name for the user.\"\"\"\n if self.nick_name:\n return self.nick_name\n return self.first_name + \" \" + self.last_name\n
"},{"location":"reference/trombi/models/#trombi.models.User.get_display_name","title":"get_display_name()
","text":"Returns the display name of the user.
A nickname if possible, otherwise, the full name.
Source code incore/models.py
def get_display_name(self) -> str:\n \"\"\"Returns the display name of the user.\n\n A nickname if possible, otherwise, the full name.\n \"\"\"\n if self.nick_name:\n return \"%s (%s)\" % (self.get_full_name(), self.nick_name)\n return self.get_full_name()\n
"},{"location":"reference/trombi/models/#trombi.models.User.get_age","title":"get_age()
","text":"Returns the age.
Source code incore/models.py
def get_age(self):\n \"\"\"Returns the age.\"\"\"\n today = timezone.now()\n born = self.date_of_birth\n return (\n today.year - born.year - ((today.month, today.day) < (born.month, born.day))\n )\n
"},{"location":"reference/trombi/models/#trombi.models.User.get_family","title":"get_family(godfathers_depth=4, godchildren_depth=4)
","text":"Get the family of the user, with the given depth.
Parameters:
Name Type Description Defaultgodfathers_depth
NonNegativeInt
The number of generations of godfathers to fetch
4
godchildren_depth
NonNegativeInt
The number of generations of godchildren to fetch
4
Returns:
Type Descriptionset[through]
A list of family relationships in this user's family
Source code incore/models.py
def get_family(\n self,\n godfathers_depth: NonNegativeInt = 4,\n godchildren_depth: NonNegativeInt = 4,\n) -> set[User.godfathers.through]:\n \"\"\"Get the family of the user, with the given depth.\n\n Args:\n godfathers_depth: The number of generations of godfathers to fetch\n godchildren_depth: The number of generations of godchildren to fetch\n\n Returns:\n A list of family relationships in this user's family\n \"\"\"\n res = []\n for depth, key, reverse_key in [\n (godfathers_depth, \"from_user_id\", \"to_user_id\"),\n (godchildren_depth, \"to_user_id\", \"from_user_id\"),\n ]:\n if depth == 0:\n continue\n links = list(User.godfathers.through.objects.filter(**{key: self.id}))\n res.extend(links)\n for _ in range(1, depth): # noqa: F402 we don't care about gettext here\n ids = [getattr(c, reverse_key) for c in links]\n links = list(\n User.godfathers.through.objects.filter(\n **{f\"{key}__in\": ids}\n ).exclude(id__in=[r.id for r in res])\n )\n if not links:\n break\n res.extend(links)\n return set(res)\n
"},{"location":"reference/trombi/models/#trombi.models.User.email_user","title":"email_user(subject, message, from_email=None, **kwargs)
","text":"Sends an email to this User.
Source code incore/models.py
def email_user(self, subject, message, from_email=None, **kwargs):\n \"\"\"Sends an email to this User.\"\"\"\n if from_email is None:\n from_email = settings.DEFAULT_FROM_EMAIL\n send_mail(subject, message, from_email, [self.email], **kwargs)\n
"},{"location":"reference/trombi/models/#trombi.models.User.generate_username","title":"generate_username()
","text":"Generates a unique username based on the first and last names.
For example: Guy Carlier gives gcarlier, and gcarlier1 if the first one exists.
Returns:
Type Descriptionstr
The generated username.
Source code incore/models.py
def generate_username(self) -> str:\n \"\"\"Generates a unique username based on the first and last names.\n\n For example: Guy Carlier gives gcarlier, and gcarlier1 if the first one exists.\n\n Returns:\n The generated username.\n \"\"\"\n\n def remove_accents(data):\n return \"\".join(\n x\n for x in unicodedata.normalize(\"NFKD\", data)\n if unicodedata.category(x)[0] == \"L\"\n ).lower()\n\n user_name = (\n remove_accents(self.first_name[0] + self.last_name)\n .encode(\"ascii\", \"ignore\")\n .decode(\"utf-8\")\n )\n un_set = [u.username for u in User.objects.all()]\n if user_name in un_set:\n i = 1\n while user_name + str(i) in un_set:\n i += 1\n user_name += str(i)\n self.username = user_name\n return user_name\n
"},{"location":"reference/trombi/models/#trombi.models.User.is_owner","title":"is_owner(obj)
","text":"Determine if the object is owned by the user.
Source code incore/models.py
def is_owner(self, obj):\n \"\"\"Determine if the object is owned by the user.\"\"\"\n if hasattr(obj, \"is_owned_by\") and obj.is_owned_by(self):\n return True\n if hasattr(obj, \"owner_group\") and self.is_in_group(pk=obj.owner_group.id):\n return True\n return self.is_root\n
"},{"location":"reference/trombi/models/#trombi.models.User.can_edit","title":"can_edit(obj)
","text":"Determine if the object can be edited by the user.
Source code incore/models.py
def can_edit(self, obj):\n \"\"\"Determine if the object can be edited by the user.\"\"\"\n if hasattr(obj, \"can_be_edited_by\") and obj.can_be_edited_by(self):\n return True\n if hasattr(obj, \"edit_groups\"):\n for pk in obj.edit_groups.values_list(\"pk\", flat=True):\n if self.is_in_group(pk=pk):\n return True\n if isinstance(obj, User) and obj == self:\n return True\n return self.is_owner(obj)\n
"},{"location":"reference/trombi/models/#trombi.models.User.can_view","title":"can_view(obj)
","text":"Determine if the object can be viewed by the user.
Source code incore/models.py
def can_view(self, obj):\n \"\"\"Determine if the object can be viewed by the user.\"\"\"\n if hasattr(obj, \"can_be_viewed_by\") and obj.can_be_viewed_by(self):\n return True\n if hasattr(obj, \"view_groups\"):\n for pk in obj.view_groups.values_list(\"pk\", flat=True):\n if self.is_in_group(pk=pk):\n return True\n return self.can_edit(obj)\n
"},{"location":"reference/trombi/models/#trombi.models.User.clubs_with_rights","title":"clubs_with_rights()
","text":"The list of clubs where the user has rights
Source code incore/models.py
@cached_property\ndef clubs_with_rights(self) -> list[Club]:\n \"\"\"The list of clubs where the user has rights\"\"\"\n memberships = self.memberships.ongoing().board().select_related(\"club\")\n return [m.club for m in memberships]\n
"},{"location":"reference/trombi/models/#trombi.models.TrombiManager","title":"TrombiManager
","text":" Bases: Manager
AvailableTrombiManager
","text":" Bases: Manager
Trombi
","text":" Bases: Model
Main class of the trombi, the Trombi itself.
It contains the deadlines for the users, and the link to the club that makes its Trombi.
"},{"location":"reference/trombi/models/#trombi.models.TrombiUser","title":"TrombiUser
","text":" Bases: Model
Bound between a User
and a Trombi
.
This class is here to avoid cross-references between the core, club, and trombi modules. It also adds the pictures to the profile without needing all the security like the other SithFiles.
"},{"location":"reference/trombi/models/#trombi.models.TrombiComment","title":"TrombiComment
","text":" Bases: Model
A comment given by someone to someone else in the same Trombi instance.
"},{"location":"reference/trombi/models/#trombi.models.TrombiClubMembership","title":"TrombiClubMembership
","text":" Bases: Model
A membership in a club.
"},{"location":"reference/trombi/models/#trombi.models.get_semester_code","title":"get_semester_code(d=None)
","text":"Return the semester code of the given date. If no date is given, return the semester code of the current semester.
The semester code is an upper letter (A for autumn, P for spring), followed by the last two digits of the year. For example, the autumn semester of 2018 is \"A18\".
Parameters:
Name Type Description Defaultd
Optional[date]
the date to use to compute the semester. If None, use today's date.
None
Returns:
Type Descriptionstr
the semester code corresponding to the given date
Source code incore/utils.py
def get_semester_code(d: Optional[date] = None) -> str:\n \"\"\"Return the semester code of the given date.\n If no date is given, return the semester code of the current semester.\n\n The semester code is an upper letter (A for autumn, P for spring),\n followed by the last two digits of the year.\n For example, the autumn semester of 2018 is \"A18\".\n\n Args:\n d: the date to use to compute the semester. If None, use today's date.\n\n Returns:\n the semester code corresponding to the given date\n \"\"\"\n if d is None:\n d = localdate()\n\n start = get_start_of_semester(d)\n\n if (start.month, start.day) == settings.SITH_SEMESTER_START_AUTUMN:\n return \"A\" + str(start.year)[-2:]\n return \"P\" + str(start.year)[-2:]\n
"},{"location":"reference/trombi/views/","title":"Views","text":""},{"location":"reference/trombi/views/#trombi.views.Club","title":"Club
","text":" Bases: Model
The Club class, made as a tree to allow nice tidy organization.
"},{"location":"reference/trombi/views/#trombi.views.Club.check_loop","title":"check_loop()
","text":"Raise a validation error when a loop is found within the parent list.
Source code inclub/models.py
def check_loop(self):\n \"\"\"Raise a validation error when a loop is found within the parent list.\"\"\"\n objs = []\n cur = self\n while cur.parent is not None:\n if cur in objs:\n raise ValidationError(_(\"You can not make loops in clubs\"))\n objs.append(cur)\n cur = cur.parent\n
"},{"location":"reference/trombi/views/#trombi.views.Club.is_owned_by","title":"is_owned_by(user)
","text":"Method to see if that object can be super edited by the given user.
Source code inclub/models.py
def is_owned_by(self, user):\n \"\"\"Method to see if that object can be super edited by the given user.\"\"\"\n if user.is_anonymous:\n return False\n return user.is_board_member\n
"},{"location":"reference/trombi/views/#trombi.views.Club.can_be_edited_by","title":"can_be_edited_by(user)
","text":"Method to see if that object can be edited by the given user.
Source code inclub/models.py
def can_be_edited_by(self, user):\n \"\"\"Method to see if that object can be edited by the given user.\"\"\"\n return self.has_rights_in_club(user)\n
"},{"location":"reference/trombi/views/#trombi.views.Club.can_be_viewed_by","title":"can_be_viewed_by(user)
","text":"Method to see if that object can be seen by the given user.
Source code inclub/models.py
def can_be_viewed_by(self, user):\n \"\"\"Method to see if that object can be seen by the given user.\"\"\"\n sub = User.objects.filter(pk=user.pk).first()\n if sub is None:\n return False\n return sub.was_subscribed\n
"},{"location":"reference/trombi/views/#trombi.views.Club.get_membership_for","title":"get_membership_for(user)
","text":"Return the current membership the given user.
NoteThe result is cached.
Source code inclub/models.py
def get_membership_for(self, user: User) -> Membership | None:\n \"\"\"Return the current membership the given user.\n\n Note:\n The result is cached.\n \"\"\"\n if user.is_anonymous:\n return None\n membership = cache.get(f\"membership_{self.id}_{user.id}\")\n if membership == \"not_member\":\n return None\n if membership is None:\n membership = self.members.filter(user=user, end_date=None).first()\n if membership is None:\n cache.set(f\"membership_{self.id}_{user.id}\", \"not_member\")\n else:\n cache.set(f\"membership_{self.id}_{user.id}\", membership)\n return membership\n
"},{"location":"reference/trombi/views/#trombi.views.User","title":"User
","text":" Bases: AbstractBaseUser
Defines the base user class, useable in every app.
This is almost the same as the auth module AbstractUser since it inherits from it, but some fields are required, and the username is generated automatically with the name of the user (see generate_username()).
Added field: nick_name, date_of_birth Required fields: email, first_name, last_name, date_of_birth
"},{"location":"reference/trombi/views/#trombi.views.User.cached_groups","title":"cached_groups: list[Group]
property
","text":"Get the list of groups this user is in.
The result is cached for the default duration (should be 5 minutes)
Returns: A list of all the groups this user is in.
"},{"location":"reference/trombi/views/#trombi.views.User.is_in_group","title":"is_in_group(*, pk=None, name=None)
","text":"Check if this user is in the given group. Either a group id or a group name must be provided. If both are passed, only the id will be considered.
The group will be fetched using the given parameter. If no group is found, return False. If a group is found, check if this user is in the latter.
Returns:
Type Descriptionbool
True if the user is the group, else False
Source code incore/models.py
def is_in_group(self, *, pk: int | None = None, name: str | None = None) -> bool:\n \"\"\"Check if this user is in the given group.\n Either a group id or a group name must be provided.\n If both are passed, only the id will be considered.\n\n The group will be fetched using the given parameter.\n If no group is found, return False.\n If a group is found, check if this user is in the latter.\n\n Returns:\n True if the user is the group, else False\n \"\"\"\n if pk is not None:\n group: Optional[Group] = get_group(pk=pk)\n elif name is not None:\n group: Optional[Group] = get_group(name=name)\n else:\n raise ValueError(\"You must either provide the id or the name of the group\")\n if group is None:\n return False\n if group.id == settings.SITH_GROUP_PUBLIC_ID:\n return True\n if group.id == settings.SITH_GROUP_SUBSCRIBERS_ID:\n return self.is_subscribed\n if group.id == settings.SITH_GROUP_OLD_SUBSCRIBERS_ID:\n return self.was_subscribed\n if group.id == settings.SITH_GROUP_ROOT_ID:\n return self.is_root\n if group.is_meta:\n # check if this group is associated with a club\n group.__class__ = MetaGroup\n club = group.associated_club\n if club is None:\n return False\n membership = club.get_membership_for(self)\n if membership is None:\n return False\n if group.name.endswith(settings.SITH_MEMBER_SUFFIX):\n return True\n return membership.role > settings.SITH_MAXIMUM_FREE_ROLE\n return group in self.cached_groups\n
"},{"location":"reference/trombi/views/#trombi.views.User.age","title":"age()
","text":"Return the age this user has the day the method is called. If the user has not filled his age, return 0.
Source code incore/models.py
@cached_property\ndef age(self) -> int:\n \"\"\"Return the age this user has the day the method is called.\n If the user has not filled his age, return 0.\n \"\"\"\n if self.date_of_birth is None:\n return 0\n today = timezone.now()\n age = today.year - self.date_of_birth.year\n # remove a year if this year's birthday is yet to come\n age -= (today.month, today.day) < (\n self.date_of_birth.month,\n self.date_of_birth.day,\n )\n return age\n
"},{"location":"reference/trombi/views/#trombi.views.User.get_full_name","title":"get_full_name()
","text":"Returns the first_name plus the last_name, with a space in between.
Source code incore/models.py
def get_full_name(self):\n \"\"\"Returns the first_name plus the last_name, with a space in between.\"\"\"\n full_name = \"%s %s\" % (self.first_name, self.last_name)\n return full_name.strip()\n
"},{"location":"reference/trombi/views/#trombi.views.User.get_short_name","title":"get_short_name()
","text":"Returns the short name for the user.
Source code incore/models.py
def get_short_name(self):\n \"\"\"Returns the short name for the user.\"\"\"\n if self.nick_name:\n return self.nick_name\n return self.first_name + \" \" + self.last_name\n
"},{"location":"reference/trombi/views/#trombi.views.User.get_display_name","title":"get_display_name()
","text":"Returns the display name of the user.
A nickname if possible, otherwise, the full name.
Source code incore/models.py
def get_display_name(self) -> str:\n \"\"\"Returns the display name of the user.\n\n A nickname if possible, otherwise, the full name.\n \"\"\"\n if self.nick_name:\n return \"%s (%s)\" % (self.get_full_name(), self.nick_name)\n return self.get_full_name()\n
"},{"location":"reference/trombi/views/#trombi.views.User.get_age","title":"get_age()
","text":"Returns the age.
Source code incore/models.py
def get_age(self):\n \"\"\"Returns the age.\"\"\"\n today = timezone.now()\n born = self.date_of_birth\n return (\n today.year - born.year - ((today.month, today.day) < (born.month, born.day))\n )\n
"},{"location":"reference/trombi/views/#trombi.views.User.get_family","title":"get_family(godfathers_depth=4, godchildren_depth=4)
","text":"Get the family of the user, with the given depth.
Parameters:
Name Type Description Defaultgodfathers_depth
NonNegativeInt
The number of generations of godfathers to fetch
4
godchildren_depth
NonNegativeInt
The number of generations of godchildren to fetch
4
Returns:
Type Descriptionset[through]
A list of family relationships in this user's family
Source code incore/models.py
def get_family(\n self,\n godfathers_depth: NonNegativeInt = 4,\n godchildren_depth: NonNegativeInt = 4,\n) -> set[User.godfathers.through]:\n \"\"\"Get the family of the user, with the given depth.\n\n Args:\n godfathers_depth: The number of generations of godfathers to fetch\n godchildren_depth: The number of generations of godchildren to fetch\n\n Returns:\n A list of family relationships in this user's family\n \"\"\"\n res = []\n for depth, key, reverse_key in [\n (godfathers_depth, \"from_user_id\", \"to_user_id\"),\n (godchildren_depth, \"to_user_id\", \"from_user_id\"),\n ]:\n if depth == 0:\n continue\n links = list(User.godfathers.through.objects.filter(**{key: self.id}))\n res.extend(links)\n for _ in range(1, depth): # noqa: F402 we don't care about gettext here\n ids = [getattr(c, reverse_key) for c in links]\n links = list(\n User.godfathers.through.objects.filter(\n **{f\"{key}__in\": ids}\n ).exclude(id__in=[r.id for r in res])\n )\n if not links:\n break\n res.extend(links)\n return set(res)\n
"},{"location":"reference/trombi/views/#trombi.views.User.email_user","title":"email_user(subject, message, from_email=None, **kwargs)
","text":"Sends an email to this User.
Source code incore/models.py
def email_user(self, subject, message, from_email=None, **kwargs):\n \"\"\"Sends an email to this User.\"\"\"\n if from_email is None:\n from_email = settings.DEFAULT_FROM_EMAIL\n send_mail(subject, message, from_email, [self.email], **kwargs)\n
"},{"location":"reference/trombi/views/#trombi.views.User.generate_username","title":"generate_username()
","text":"Generates a unique username based on the first and last names.
For example: Guy Carlier gives gcarlier, and gcarlier1 if the first one exists.
Returns:
Type Descriptionstr
The generated username.
Source code incore/models.py
def generate_username(self) -> str:\n \"\"\"Generates a unique username based on the first and last names.\n\n For example: Guy Carlier gives gcarlier, and gcarlier1 if the first one exists.\n\n Returns:\n The generated username.\n \"\"\"\n\n def remove_accents(data):\n return \"\".join(\n x\n for x in unicodedata.normalize(\"NFKD\", data)\n if unicodedata.category(x)[0] == \"L\"\n ).lower()\n\n user_name = (\n remove_accents(self.first_name[0] + self.last_name)\n .encode(\"ascii\", \"ignore\")\n .decode(\"utf-8\")\n )\n un_set = [u.username for u in User.objects.all()]\n if user_name in un_set:\n i = 1\n while user_name + str(i) in un_set:\n i += 1\n user_name += str(i)\n self.username = user_name\n return user_name\n
"},{"location":"reference/trombi/views/#trombi.views.User.is_owner","title":"is_owner(obj)
","text":"Determine if the object is owned by the user.
Source code incore/models.py
def is_owner(self, obj):\n \"\"\"Determine if the object is owned by the user.\"\"\"\n if hasattr(obj, \"is_owned_by\") and obj.is_owned_by(self):\n return True\n if hasattr(obj, \"owner_group\") and self.is_in_group(pk=obj.owner_group.id):\n return True\n return self.is_root\n
"},{"location":"reference/trombi/views/#trombi.views.User.can_edit","title":"can_edit(obj)
","text":"Determine if the object can be edited by the user.
Source code incore/models.py
def can_edit(self, obj):\n \"\"\"Determine if the object can be edited by the user.\"\"\"\n if hasattr(obj, \"can_be_edited_by\") and obj.can_be_edited_by(self):\n return True\n if hasattr(obj, \"edit_groups\"):\n for pk in obj.edit_groups.values_list(\"pk\", flat=True):\n if self.is_in_group(pk=pk):\n return True\n if isinstance(obj, User) and obj == self:\n return True\n return self.is_owner(obj)\n
"},{"location":"reference/trombi/views/#trombi.views.User.can_view","title":"can_view(obj)
","text":"Determine if the object can be viewed by the user.
Source code incore/models.py
def can_view(self, obj):\n \"\"\"Determine if the object can be viewed by the user.\"\"\"\n if hasattr(obj, \"can_be_viewed_by\") and obj.can_be_viewed_by(self):\n return True\n if hasattr(obj, \"view_groups\"):\n for pk in obj.view_groups.values_list(\"pk\", flat=True):\n if self.is_in_group(pk=pk):\n return True\n return self.can_edit(obj)\n
"},{"location":"reference/trombi/views/#trombi.views.User.clubs_with_rights","title":"clubs_with_rights()
","text":"The list of clubs where the user has rights
Source code incore/models.py
@cached_property\ndef clubs_with_rights(self) -> list[Club]:\n \"\"\"The list of clubs where the user has rights\"\"\"\n memberships = self.memberships.ongoing().board().select_related(\"club\")\n return [m.club for m in memberships]\n
"},{"location":"reference/trombi/views/#trombi.views.CanCreateMixin","title":"CanCreateMixin
","text":" Bases: View
Protect any child view that would create an object.
Raises:
Type DescriptionPermissionDenied
If the user has not the necessary permission to create the object of the view.
"},{"location":"reference/trombi/views/#trombi.views.CanEditMixin","title":"CanEditMixin
","text":" Bases: GenericContentPermissionMixinBuilder
Ensure the user has permission to edit this view's object.
Raises:
Type DescriptionPermissionDenied
if the user cannot edit this view's object.
"},{"location":"reference/trombi/views/#trombi.views.CanEditPropMixin","title":"CanEditPropMixin
","text":" Bases: GenericContentPermissionMixinBuilder
Ensure the user has owner permissions on the child view object.
In other word, you can make a view with this view as parent, and it will be retricted to the users that are in the object's owner_group or that pass the obj.can_be_viewed_by
test.
Raises:
Type DescriptionPermissionDenied
If the user cannot see the object
"},{"location":"reference/trombi/views/#trombi.views.CanViewMixin","title":"CanViewMixin
","text":" Bases: GenericContentPermissionMixinBuilder
Ensure the user has permission to view this view's object.
Raises:
Type DescriptionPermissionDenied
if the user cannot edit this view's object.
"},{"location":"reference/trombi/views/#trombi.views.QuickNotifMixin","title":"QuickNotifMixin
","text":""},{"location":"reference/trombi/views/#trombi.views.QuickNotifMixin.get_context_data","title":"get_context_data(**kwargs)
","text":"Add quick notifications to context.
Source code incore/views/__init__.py
def get_context_data(self, **kwargs):\n \"\"\"Add quick notifications to context.\"\"\"\n kwargs = super().get_context_data(**kwargs)\n kwargs[\"quick_notifs\"] = []\n for n in self.quick_notif_list:\n kwargs[\"quick_notifs\"].append(settings.SITH_QUICK_NOTIF[n])\n for key, val in settings.SITH_QUICK_NOTIF.items():\n for gk in self.request.GET:\n if key == gk:\n kwargs[\"quick_notifs\"].append(val)\n return kwargs\n
"},{"location":"reference/trombi/views/#trombi.views.TabedViewMixin","title":"TabedViewMixin
","text":" Bases: View
Basic functions for displaying tabs in the template.
"},{"location":"reference/trombi/views/#trombi.views.Trombi","title":"Trombi
","text":" Bases: Model
Main class of the trombi, the Trombi itself.
It contains the deadlines for the users, and the link to the club that makes its Trombi.
"},{"location":"reference/trombi/views/#trombi.views.TrombiClubMembership","title":"TrombiClubMembership
","text":" Bases: Model
A membership in a club.
"},{"location":"reference/trombi/views/#trombi.views.TrombiComment","title":"TrombiComment
","text":" Bases: Model
A comment given by someone to someone else in the same Trombi instance.
"},{"location":"reference/trombi/views/#trombi.views.TrombiUser","title":"TrombiUser
","text":" Bases: Model
Bound between a User
and a Trombi
.
This class is here to avoid cross-references between the core, club, and trombi modules. It also adds the pictures to the profile without needing all the security like the other SithFiles.
"},{"location":"reference/trombi/views/#trombi.views.TrombiTabsMixin","title":"TrombiTabsMixin
","text":" Bases: TabedViewMixin
UserIsInATrombiMixin
","text":" Bases: View
Check if the requested user has a trombi_user attribute.
"},{"location":"reference/trombi/views/#trombi.views.TrombiForm","title":"TrombiForm
","text":" Bases: ModelForm
TrombiCreateView
","text":" Bases: CanCreateMixin
, CreateView
Create a trombi for a club.
"},{"location":"reference/trombi/views/#trombi.views.TrombiCreateView.post","title":"post(request, *args, **kwargs)
","text":"Affect club.
Source code intrombi/views.py
def post(self, request, *args, **kwargs):\n \"\"\"Affect club.\"\"\"\n form = self.get_form()\n if form.is_valid():\n club = get_object_or_404(Club, id=self.kwargs[\"club_id\"])\n form.instance.club = club\n ret = self.form_valid(form)\n return ret\n else:\n return self.form_invalid(form)\n
"},{"location":"reference/trombi/views/#trombi.views.TrombiEditView","title":"TrombiEditView
","text":" Bases: CanEditPropMixin
, TrombiTabsMixin
, UpdateView
AddUserForm
","text":" Bases: Form
TrombiDetailView
","text":" Bases: CanEditMixin
, QuickNotifMixin
, TrombiTabsMixin
, DetailView
TrombiExportView
","text":" Bases: CanEditMixin
, TrombiTabsMixin
, DetailView
TrombiDeleteUserView
","text":" Bases: CanEditPropMixin
, TrombiTabsMixin
, DeleteView
TrombiModerateCommentsView
","text":" Bases: CanEditPropMixin
, QuickNotifMixin
, TrombiTabsMixin
, DetailView
TrombiModerateForm
","text":" Bases: Form
TrombiModerateCommentView
","text":" Bases: DetailView
TrombiModelChoiceField
","text":" Bases: ModelChoiceField
UserTrombiForm
","text":" Bases: Form
UserTrombiToolsView
","text":" Bases: LoginRequiredMixin
, QuickNotifMixin
, TrombiTabsMixin
, TemplateView
Display a user's trombi tools.
"},{"location":"reference/trombi/views/#trombi.views.UserTrombiEditPicturesView","title":"UserTrombiEditPicturesView
","text":" Bases: TrombiTabsMixin
, UserIsInATrombiMixin
, UpdateView
UserTrombiEditProfileView
","text":" Bases: QuickNotifMixin
, TrombiTabsMixin
, UserIsInATrombiMixin
, UpdateView
UserTrombiResetClubMembershipsView
","text":" Bases: UserIsInATrombiMixin
, RedirectView
UserTrombiDeleteMembershipView
","text":" Bases: TrombiTabsMixin
, CanEditMixin
, DeleteView
UserTrombiAddMembershipView
","text":" Bases: TrombiTabsMixin
, CreateView
UserTrombiEditMembershipView
","text":" Bases: CanEditMixin
, TrombiTabsMixin
, UpdateView
UserTrombiProfileView
","text":" Bases: TrombiTabsMixin
, DetailView
TrombiCommentFormView
","text":" Bases: LoginRequiredMixin
, View
Create/edit a trombi comment.
"},{"location":"reference/trombi/views/#trombi.views.TrombiCommentCreateView","title":"TrombiCommentCreateView
","text":" Bases: TrombiCommentFormView
, CreateView
TrombiCommentEditView
","text":" Bases: TrombiCommentFormView
, CanViewMixin
, UpdateView
Le projet n'est en aucun cas li\u00e9 \u00e0 un quelconque environnement de d\u00e9veloppement. Il est possible pour chacun de travailler avec les outils dont il a envie et d'utiliser l'\u00e9diteur de code avec lequel il est le plus \u00e0 l'aise.
Pour donner une id\u00e9e, Skia a \u00e9crit une \u00e9norme partie de projet avec l'\u00e9diteur Vim sur du GNU/Linux alors que Sli a utilis\u00e9 Sublime Text sur MacOS et que Mar\u00e9chal travaille avec PyCharm sur Windows muni de WSL Arch Linux btw.
"},{"location":"tutorial/devtools/#configurer-les-pre-commit-hooks","title":"Configurer les pre-commit hooks","text":"La proc\u00e9dure habituelle pour contribuer au projet consiste \u00e0 commit des modifications, puis \u00e0 les push sur le d\u00e9p\u00f4t distant et \u00e0 ouvrir une pull request. Cette PR va faire tourner les outils de v\u00e9rification de la qualit\u00e9 de code. Si la v\u00e9rification \u00e9choue, la PR est bloqu\u00e9e, et il faut r\u00e9parer le probl\u00e8me (ce qui implique de push un micro-commit ou de push force sur la branche).
Dans l'id\u00e9al, on aimerait donc qu'il soit impossible d'oublier de faire tourner ces v\u00e9rifications. Pour \u00e7a, il existe un m\u00e9canisme : les pre-commits hooks. Ce sont des actions qui tournent automatiquement lorsque vous effectuez un git commit
. Ces derni\u00e8res vont analyser et \u00e9ventuellement modifier le code, avant que Git n'ajoute effectivement le commit sur l'arbre git. Voyez \u00e7a comme une micro-CI qui tourne en local.
Les git hooks sont une fonctionnalit\u00e9 par d\u00e9faut de Git. Cependant, leur configuration peut-\u00eatre un peu emb\u00eatante si vous le faites manuellement. Pour g\u00e9rer \u00e7a plus simplement, nous utilisons le logiciel python pre-commit qui permet de contr\u00f4ler leur installation via un seul fichier de configuration, plac\u00e9 \u00e0 la racine du projet (plus pr\u00e9cis\u00e9ment, il s'agit du fichier .pre-commit-config.yaml
).
Note
Les pre-commits sont \u00e9galement utilis\u00e9s dans la CI. Si ces derniers fonctionnent localement, vous avez la garantie que la pipeline ne sera pas fach\u00e9e. ;)
C'est une fonctionnalit\u00e9 de git lui-m\u00eame, mais c'est assez emb\u00eatant \u00e0 g\u00e9rer manuellement. Pour g\u00e9rer \u00e7a plus simplement, nous utilisons le logiciel python pre-commit qui permet de contr\u00f4ller leur installation via un fichier yaml.
Le logiciel est install\u00e9 par d\u00e9faut par poetry. Il suffit ensuite de lancer :
pre-commit install\n
Une fois que vous avez fait cette commande, pre-commit tournera automatiquement chaque fois que vous ferez un nouveau commit. Il est \u00e9galement possible d'appeler soi-m\u00eame les pre-commits :
pre-commit run --all-files\n
"},{"location":"tutorial/devtools/#configurer-ruff-pour-son-editeur","title":"Configurer Ruff pour son \u00e9diteur","text":"Note
Ruff est inclus dans les d\u00e9pendances du projet. Si vous avez r\u00e9ussi \u00e0 terminer l'installation, vous n'avez donc pas de configuration suppl\u00e9mentaire \u00e0 effectuer.
Pour utiliser Ruff, placez-vous \u00e0 la racine du projet et lancez la commande suivante :
ruff format # pour formatter le code\nruff check # pour linter le code\n
Ruff va alors faire son travail sur l'ensemble du projet puis vous dire si des documents ont \u00e9t\u00e9 reformat\u00e9s (si vous avez fait ruff format
) ou bien s'il y a des erreurs \u00e0 r\u00e9parer (si vous avez faire ruff check
).
Appeler Ruff en ligne de commandes avant de pousser votre code sur Github est une technique qui marche tr\u00e8s bien. Cependant, vous risquez de souvent l'oublier. Or, lorsque le code ne respecte pas les standards de qualit\u00e9, la pipeline bloque les PR sur les branches prot\u00e9g\u00e9es.
Pour \u00e9viter de vous faire r\u00e9guli\u00e8rement avoir, vous pouvez configurer votre \u00e9diteur pour que Ruff fasse son travail automatiquement \u00e0 chaque \u00e9dition d'un fichier. Nous tenterons de vous faire ici un r\u00e9sum\u00e9 pour deux \u00e9diteurs de textes populaires que sont VsCode et Sublime Text.
VsCodeSublime TextInstallez l'extension Ruff pour VsCode. Ensuite, ajoutez ceci dans votre configuration :
{\n \"[python]\": {\n \"editor.formatOnSave\": true,\n \"editor.defaultFormatter\": \"charliermarsh.ruff\"\n }\n}\n
Vous devez installer le plugin LSP-ruff. Suivez ensuite les instructions donn\u00e9es dans la description du plugin.
Dans la configuration de votre projet, ajoutez ceci:
{\n \"settings\": {\n \"lsp_format_on_save\": true, // Commun \u00e0 ruff et biome\n \"LSP\": { \n \"LSP-ruff\": {\n \"enabled\": true,\n }\n }\n }\n}\n
Si vous utilisez le plugin anaconda, pensez \u00e0 modifier les param\u00e8tres du linter pep8 pour \u00e9viter de recevoir des warnings dans le formatage de ruff comme ceci :
{\n \"pep8_ignore\": [\n \"E203\",\n \"E266\",\n \"E501\",\n \"W503\"\n ]\n}\n
"},{"location":"tutorial/devtools/#configurer-biome-pour-son-editeur","title":"Configurer Biome pour son \u00e9diteur","text":"Note
Biome est inclus dans les d\u00e9pendances du projet. Si vous avez r\u00e9ussi \u00e0 terminer l'installation, vous n'avez donc pas de configuration suppl\u00e9mentaire \u00e0 effectuer.
Pour utiliser Biome, placez-vous \u00e0 la racine du projet et lancer la commande suivante:
npx @biomejs/biome check # Pour checker le code avec le linter et le formater\n npx @biomejs/biome check --write # Pour appliquer les changemnts\n
Biome va alors faire son travail sur l'ensemble du projet puis vous dire si des documents ont \u00e9t\u00e9 reformat\u00e9s (si vous avez fait npx @biomejs/biome format --write
) ou bien s'il y a des erreurs \u00e0 r\u00e9parer (si vous avez faire npx @biomejs/biome lint
) ou les deux (si vous avez fait npx @biomejs/biome check --write
).
Appeler Biome en ligne de commandes avant de pousser votre code sur Github est une technique qui marche tr\u00e8s bien. Cependant, vous risquez de souvent l'oublier. Or, lorsque le code ne respecte pas les Biomes de qualit\u00e9, la pipeline bloque les PR sur les branches prot\u00e9g\u00e9es.
Pour \u00e9viter de vous faire r\u00e9guli\u00e8rement avoir, vous pouvez configurer votre \u00e9diteur pour que Biome fasse son travail automatiquement \u00e0 chaque \u00e9dition d'un fichier. Nous tenterons de vous faire ici un r\u00e9sum\u00e9 pour deux \u00e9diteurs de textes populaires que sont VsCode et Sublime Text.
VsCodeSublime TextBiome est fourni par le plugin Biome.
Ensuite, ajoutez ceci dans votre configuration :
{\n \"[javascript]\": {\n \"editor.formatOnSave\": true,\n \"editor.defaultFormatter\": \"biomejs.biome\"\n },\n \"[typescript]\": {\n \"editor.formatOnSave\": true,\n \"editor.defaultFormatter\": \"biomejs.biome\"\n }\n}\n
Tout comme pour ruff, il suffit d'installer un plugin lsp LSP-biome.
Et enfin, dans la configuration de votre projet, ajouter les lignes suivantes :
{\n \"settings\": {\n \"lsp_format_on_save\": true, // Commun \u00e0 ruff et biome\n \"LSP\": { \n \"LSP-biome\": {\n \"enabled\": true,\n }\n }\n }\n}\n
"},{"location":"tutorial/etransaction/","title":"Etransactions","text":"La boutique en ligne n\u00e9cessite une interaction avec la banque pour son fonctionnement.
Malheureusement, la mani\u00e8re dont cette interaction marche est trop complexe pour \u00eatre r\u00e9sum\u00e9e ici.
Nous ne pouvons donc que vous redirigez vers la doc du cr\u00e9dit agricole : https://www.ca-moncommerce.com/espace-client-mon-commerce/up2pay-e-transactions/ma-documentation/
"},{"location":"tutorial/groups/","title":"Gestion des groupes","text":"Il existe deux types de groupes sur le site AE :
Les deux types de groupes sont stock\u00e9s dans la m\u00eame table en base de donn\u00e9es, et ne sont diff\u00e9renci\u00e9s que par un attribut is_meta
.
Pour plus diff\u00e9rencier l'utilisation de ces deux types de groupe, il a \u00e9t\u00e9 cr\u00e9\u00e9 une classe proxy (c'est-\u00e0-dire qu'elle ne correspond pas \u00e0 une vraie table en base de donn\u00e9e) qui encapsule leur utilisation. RealGroup
peut \u00eatre utilis\u00e9 pour cr\u00e9er des groupes r\u00e9els dans le code et pour faire une recherche sur ceux-ci (dans le cadre d'une v\u00e9rification de permissions par exemple).
Les m\u00e9ta-groupes, comme expliqu\u00e9 pr\u00e9c\u00e9demment, sont utilis\u00e9s dans les contextes o\u00f9 il est n\u00e9cessaire de cr\u00e9er un groupe dynamiquement. Les objets MetaGroup
, bien que dynamiques, doivent tout de m\u00eame s'enregistrer en base de donn\u00e9es comme des vrais groupes afin de pouvoir \u00eatre affect\u00e9s dans les permissions d'autres objets, comme un forum ou une page de wiki par exemple. C'est principalement utilis\u00e9 au travers des clubs, qui g\u00e9n\u00e8rent automatiquement deux groupes \u00e0 leur cr\u00e9ation :
settings.SITH_MAXIMUM_FREE_ROLE
.settings.SITH_MAXIMUM_FREE_ROLE
.Les groupes r\u00e9els que l'on utilise dans le site sont les suivants :
Groupes g\u00e9r\u00e9s automatiquement par le site :
Public
: tous les utilisateurs du siteSubscribers
: tous les cotisants du siteOld subscribers
: tous les anciens cotisantsGroupes g\u00e9r\u00e9s par les administrateurs (\u00e0 appliquer \u00e0 la main sur un utilisateur) :
Root
: administrateur global du siteAccounting admin
: les administrateurs de la comptabilit\u00e9Communication admin
: les administrateurs de la communicationCounter admin
: les administrateurs des comptoirs (foyer et autre)SAS admin
: les administrateurs du SASForum admin
: les administrateurs du forumPedagogy admin
: les administrateurs de la p\u00e9dagogie (guide des UVs)Banned from buying alcohol
: les utilisateurs interdits de vente d'alcool (non mineurs)Banned from counters
: les utilisateurs interdits d'utilisation des comptoirsBanned to subscribe
: les utilisateurs interdits de cotisationSi le projet marche chez vous apr\u00e8s avoir suivi les \u00e9tapes donn\u00e9es dans la page pr\u00e9c\u00e9dente, alors vous pouvez d\u00e9velopper. Ce que nous nous vous avons pr\u00e9sent\u00e9 n'est absolument pas la m\u00eame 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\u00e9veloppement soit encore plus proche de celui en production. Voici les \u00e9tapes \u00e0 suivre pour \u00e7a.
Tip
Configurer les d\u00e9pendances du projet peut demander beaucoup d'allers et retours entre votre r\u00e9pertoire projet et divers autres emplacements.
Vous pouvez gagner du temps en d\u00e9clarant un alias :
bash/zshnualias cdp=\"cd /repertoire/du/projet\"\n
alias cdp = cd /repertoire/du/projet\n
Chaque fois qu'on vous demandera de retourner au r\u00e9pertoire projet, vous aurez juste \u00e0 faire :
cdp\n
"},{"location":"tutorial/install-advanced/#installer-les-dependances-manquantes","title":"Installer les d\u00e9pendances manquantes","text":"Pour installer compl\u00e8tement le projet, il va falloir quelques d\u00e9pendances en plus. Commencez par installer les d\u00e9pendances syst\u00e8me :
LinuxmacOS Debian/UbuntuArch Linuxsudo apt install postgresql redis libq-dev nginx\n
sudo pacman -S postgresql redis nginx\n
brew install postgresql redis lipbq nginx\nexport PATH=\"/usr/local/opt/libpq/bin:$PATH\"\nsource ~/.zshrc\n
Puis, installez les d\u00e9pendances poetry n\u00e9cessaires en prod :
poetry install --with prod\n
Info
Certaines d\u00e9pendances peuvent \u00eatre un peu longues \u00e0 installer (notamment psycopg-c). C'est parce que ces d\u00e9pendances compilent certains modules \u00e0 l'installation.
"},{"location":"tutorial/install-advanced/#configurer-redis","title":"Configurer Redis","text":"Redis est utilis\u00e9 comme cache. Assurez-vous qu'il tourne :
sudo systemctl redis status\n
Et s'il ne tourne pas, d\u00e9marrez-le :
sudo systemctl start redis\nsudo systemctl enable redis # si vous voulez que redis d\u00e9marre automatiquement au boot\n
Puis ajoutez le code suivant \u00e0 la fin de votre fichier settings_custom.py
:
CACHES = {\n \"default\": {\n \"BACKEND\": \"django.core.cache.backends.redis.RedisCache\",\n \"LOCATION\": \"redis://127.0.0.1:6379\",\n }\n}\n
"},{"location":"tutorial/install-advanced/#configurer-postgresql","title":"Configurer PostgreSQL","text":"PostgreSQL est utilis\u00e9 comme base de donn\u00e9es.
Passez sur le compte de l'utilisateur postgres et lancez l'invite de commande sql :
sudo su - postgres\npsql\n
Puis configurez la base de donn\u00e9es :
CREATE DATABASE sith;\nCREATE USER sith WITH PASSWORD 'password';\n\nALTER ROLE sith SET client_encoding TO 'utf8';\nALTER ROLE sith SET default_transaction_isolation TO 'read committed';\nALTER ROLE sith SET timezone TO 'UTC';\n\nGRANT ALL PRIVILEGES ON DATABASE sith TO SITH;\n\\q\n
Si vous utilisez une version de PostgreSQL sup\u00e9rieure ou \u00e9gale \u00e0 15, vous devez ex\u00e9cuter une commande en plus, en \u00e9tant connect\u00e9 en tant que postgres :
psql -d sith -c \"GRANT ALL PRIVILEGES ON SCHEMA public to sith\";\n
Puis ajoutez le code suivant \u00e0 la fin de votre settings_custom.py
:
DATABASES = {\n \"default\": {\n \"ENGINE\": \"django.db.backends.postgresql\",\n \"NAME\": \"sith\",\n \"USER\": \"sith\",\n \"PASSWORD\": \"password\",\n \"HOST\": \"localhost\",\n \"PORT\": \"\", # laissez ce champ vide pour que le choix du port soit automatique\n }\n}\n
Enfin, cr\u00e9ez vos donn\u00e9es :
poetry run ./manage.py populate\n
Note
N'oubliez de quitter la session de l'utilisateur postgres apr\u00e8s avoir configur\u00e9 la db.
"},{"location":"tutorial/install-advanced/#configurer-nginx","title":"Configurer nginx","text":"Nginx est utilis\u00e9 comme reverse-proxy.
Warning
Nginx ne sert pas les fichiers de la m\u00eame mani\u00e8re que Django. Les fichiers statiques servis seront ceux du dossier /static
, tels que g\u00e9n\u00e9r\u00e9s 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\u00e9t\u00e9s.
De mani\u00e8re g\u00e9n\u00e9rale, utiliser nginx en dev n'est pas tr\u00e8s utile, voire est g\u00eanant si vous travaillez sur le front. Ne vous emb\u00eatez pas avec \u00e7a, sauf par curiosit\u00e9 intellectuelle, ou bien si vous voulez tester sp\u00e9cifiquement des interactions avec le reverse proxy.
Placez-vous dans le r\u00e9pertoire /etc/nginx
, et cr\u00e9ez les dossiers et fichiers n\u00e9cessaires :
cd /etc/nginx/\nsudo mkdir sites-enabled sites-available\nsudo touch sites-available/sith.conf\nsudo ln -s /etc/nginx/sites-available/sith.conf sites-enabled/sith.conf\n
Puis ouvrez le fichier sites-available/sith.conf
et mettez-y le contenu suivant :
server {\n listen 8000;\n\n server_name _;\n\n location /static/;\n root /repertoire/du/projet;\n }\n location ~ ^/data/(products|com|club_logos)/ {\n root /repertoire/du/projet;\n }\n location ~ ^/data/(SAS|profiles|users|.compressed|.thumbnails)/ {\n # https://nginx.org/en/docs/http/ngx_http_core_module.html#internal\n internal;\n root /repertoire/du/projet;\n }\n\n location / {\n proxy_pass http://127.0.0.1:8001;\n include uwsgi_params;\n }\n}\n
Ouvrez le fichier nginx.conf
, et ajoutez la configuration suivante :
http {\n # Toute la configuration\n # \u00e9ventuellement d\u00e9j\u00e0 l\u00e0\n\n include /etc/nginx/sites-enabled/sith.conf;\n}\n
V\u00e9rifiez que votre configuration est bonne :
sudo nginx -t\n
Si votre configuration n'est pas bonne, corrigez-la. Puis lancez ou relancez nginx :
sudo systemctl restart nginx\n
Dans votre settings_custom.py
, remplacez DEBUG=True
par DEBUG=False
.
Enfin, d\u00e9marrez le serveur Django :
cd /repertoire/du/projet\npoetry run ./manage.py runserver 8001\n
Et c'est bon, votre reverse-proxy est pr\u00eat \u00e0 tourner devant votre serveur. Nginx \u00e9coutera sur le port 8000. Toutes les requ\u00eates vers des fichiers statiques et les medias publiques seront seront servies directement par nginx. Toutes les autres requ\u00eates seront transmises au serveur django.
"},{"location":"tutorial/install-advanced/#mettre-a-jour-la-base-de-donnees-antispam","title":"Mettre \u00e0 jour la base de donn\u00e9es antispam","text":"L'anti spam n\u00e9cessite d'\u00eatre \u00e0 jour par rapport \u00e0 des bases de donn\u00e9es externes. Il existe une commande pour \u00e7a qu'il faut lancer r\u00e9guli\u00e8rement. Lors de la mise en production, il est judicieux de configurer un cron pour la mettre \u00e0 jour au moins une fois par jour.
python manage.py update_spam_database\n
"},{"location":"tutorial/install/","title":"Installer le projet","text":""},{"location":"tutorial/install/#dependances-du-systeme","title":"D\u00e9pendances du syst\u00e8me","text":"Certaines d\u00e9pendances sont n\u00e9cessaires niveau syst\u00e8me :
Si vous utilisez Windows, je suis navr\u00e9 de vous annoncer que, certaines d\u00e9pendances \u00e9tant uniquement disponibles sur des syt\u00e8mes UNIX, il n'est pas possible de d\u00e9velopper le site sur ce syst\u00e8me d'exploitation.
Heureusement, il existe une alternative qui ne requiert pas de d\u00e9sinstaller votre OS ni de mettre un dual boot sur votre ordinateur : WSL
.
# dans un shell Windows\nwsl --install\n\n# afficher la liste des distribution disponible avec WSL\nwsl -l -o\n\n# installer WSL avec une distro (ubuntu conseill\u00e9)\nwsl --install -d <nom_distro>\n
Une fois WSL
install\u00e9, mettez \u00e0 jour votre distribution et installez les d\u00e9pendances (voir la partie installation sous Ubuntu).
Pour acc\u00e9der au contenu d'un r\u00e9pertoire externe \u00e0 WSL
, il suffit d'utiliser la commande suivante :
# oui c'est beau, simple et efficace\ncd /mnt/<la_lettre_du_disque>/vos/fichiers/comme/dhab\n
Note
\u00c0 ce stade, si vous avez r\u00e9ussi votre installation de WSL
ou bien qu'il \u00e9tait d\u00e9j\u00e0 install\u00e9, vous pouvez effectuer la mise en place du projet en suivant les instructions pour votre distribution.
Avant toute chose, assurez-vous que votre syst\u00e8me est \u00e0 jour :
sudo apt update\nsudo apt upgrade\n
Puis, si ce n'est pas d\u00e9j\u00e0 fait, installez Python :
sudo apt install python3\n# on sait jamais\nsudo apt install python-is-python3\n
Si vous utilisez Ubuntu 22.04 ou Ubuntu 24.04, votre version de Python devrait \u00eatre compatible par d\u00e9faut avec celle du projet. Si ce n'est pas le cas, nous vous conseillons d'utiliser pyenv Puis installez les autres d\u00e9pendances :
sudo apt install build-essential libssl-dev libjpeg-dev zlib1g-dev python-dev npm \\\n libffi-dev python-dev-is-python3 pkg-config \\\n gettext git pipx\n\npipx install poetry\n
sudo pacman -Syu # on s'assure que les d\u00e9p\u00f4ts et le syst\u00e8me sont \u00e0 jour\n\nsudo pacman -S python\n\nsudo pacman -S gcc git gettext pkgconf python-poetry npm\n
Pour installer les d\u00e9pendances, il est fortement recommand\u00e9 d'installer le gestionnaire de paquets homebrew <https://brew.sh/index_fr>
_. Il est \u00e9galement n\u00e9cessaire d'avoir install\u00e9 xcode
brew install git python pipx npm\npipx install poetry\n\n# Pour bien configurer gettext\nbrew link gettext # (suivez bien les instructions suppl\u00e9mentaires affich\u00e9es)\n
Note
Si vous rencontrez des erreurs lors de votre configuration, n'h\u00e9sitez pas \u00e0 v\u00e9rifier l'\u00e9tat de votre installation homebrew avec :code:brew doctor
Clonez le projet (depuis votre console WSL, si vous utilisez WSL) et installez les d\u00e9pendances :
git clone https://github.com/ae-utbm/sith.git\ncd sith\n\n# Cr\u00e9ation de l'environnement et installation des d\u00e9pendances\npoetry install # D\u00e9pendances backend\nnpm install # D\u00e9pendances frontend\npoetry run ./manage.py install_xapian\n
Note
La commande install_xapian
est longue et affiche beaucoup de texte \u00e0 l'\u00e9cran. C'est normal, il ne faut pas avoir peur.
Maintenant que les d\u00e9pendances sont install\u00e9es, nous allons cr\u00e9er la base de donn\u00e9es, la remplir avec des donn\u00e9es de test, et compiler les traductions. Cependant, avant de faire cela, il est n\u00e9cessaire de modifier la configuration pour signifier que nous sommes en mode d\u00e9veloppement. Pour cela, nous allons cr\u00e9er un fichier sith/settings_custom.py
et l'utiliser pour surcharger les settings de base.
echo \"DEBUG=True\" > sith/settings_custom.py\necho 'SITH_URL = \"localhost:8000\"' >> sith/settings_custom.py\n
Enfin, nous pouvons lancer les commandes suivantes :
# Activation de l'environnement virtuel\npoetry shell\n\n# Pr\u00e9pare la base de donn\u00e9es\npython ./manage.py setup\n\n# Installe les traductions\npython ./manage.py compilemessages\n
Note
Pour \u00e9viter d'avoir \u00e0 utiliser la commande poetry shell
syst\u00e9matiquement, il est possible de consulter direnv.
Il faut toujours avoir pr\u00e9alablement activ\u00e9 l'environnement virtuel comme fait plus haut et se placer \u00e0 la racine du projet. Il suffit ensuite d'utiliser cette commande :
python manage.py runserver\n
Note
Le serveur est alors accessible \u00e0 l'adresse http://localhost:8000 ou bien http://127.0.0.1:8000/.
Tip
Vous trouverez \u00e9galement, \u00e0 l'adresse http://localhost:8000/api/docs, une interface swagger, avec toutes les routes de l'API.
"},{"location":"tutorial/install/#generer-la-documentation","title":"G\u00e9n\u00e9rer la documentation","text":"La documentation est automatiquement mise en ligne \u00e0 chaque envoi de code sur GitHub. Pour l'utiliser en local ou globalement pour la modifier, il existe une commande du site qui g\u00e9n\u00e8re la documentation et lance un serveur la rendant accessible \u00e0 l'adresse http://localhost:8080. Cette commande g\u00e9n\u00e8re la documentation \u00e0 chacune de ses modifications, inutile de relancer le serveur \u00e0 chaque fois.
Note
Les d\u00e9pendances pour la documentation sont optionnelles. Avant de commencer \u00e0 travailler sur la doc, il faut donc les installer avec la commande poetry install --with docs
mkdocs serve\n
"},{"location":"tutorial/install/#lancer-les-tests","title":"Lancer les tests","text":"Pour lancer les tests, il suffit d'utiliser la commande suivante :
# Lancer tous les tests\npytest\n\n# Lancer les tests de l'application core\npytest core\n\n# Lancer les tests de la classe UserRegistrationTest de core\npytest core/tests/tests_core.py::TestUserRegistration\n
Note
Certains tests sont un peu longs \u00e0 tourner. Pour ne faire tourner que les tests les plus rapides, vous pouvez ex\u00e9cutez pytest ainsi :
pytest -m \"not slow\"\n\n# vous pouvez toujours faire comme au-dessus\npytest core -m \"not slow\"\n
A l'inverse, vous pouvez ne faire tourner que les tests lents en rempla\u00e7ant -m \"not slow\"
par -m slow
.
De cette mani\u00e8re, votre processus de d\u00e9veloppement devrait \u00eatre un peu plus fluide. Cependant, n'oubliez pas de bien faire tourner tous les tests avant de push un commit.
"},{"location":"tutorial/perms/","title":"Gestion des permissions","text":""},{"location":"tutorial/perms/#les-permissions","title":"Les permissions","text":"Le fonctionnement de l'AE ne permet pas d'utiliser le syst\u00e8me de permissions int\u00e9gr\u00e9 \u00e0 Django tel quel. Lors de la conception du Sith, ce qui paraissait le plus simple \u00e0 l'\u00e9poque \u00e9tait de concevoir un syst\u00e8me maison afin de se calquer sur ce que faisais l'ancien site.
"},{"location":"tutorial/perms/#proteger-un-modele","title":"Prot\u00e9ger un mod\u00e8le","text":"La gestion des permissions se fait directement par mod\u00e8le. Il existe trois niveaux de permission :
Chacune de ces permissions est v\u00e9rifi\u00e9e par une m\u00e9thode d\u00e9di\u00e9e de la classe User :
Ces m\u00e9thodes vont alors r\u00e9soudre les permissions dans cet ordre :
can_be_viewed_by(user)
(ou can_be_edited_by(user)
, ou is_owned_by(user)
) et que son appel renvoie True
, l'utilisateur a la permission requise.view_groups
(ou edit_groups
, ou owner_group
) et que l'utilisateur est dans l'un des groupes indiqu\u00e9s, il a la permission requise.owner
impliquent les droits d'\u00e9dition, et les droits d'\u00e9dition impliquent les droits de vue).Voici un exemple d'impl\u00e9mentation de ce syst\u00e8me :
Avec les m\u00e9thodesAvec les groupes de permissionfrom django.db import models\nfrom django.utils.translation import gettext_lazy as _\n\nfrom core.models import User, Group\n\n# Utilisation de la protection par fonctions\nclass Article(models.Model):\n\n title = models.CharField(_(\"title\"), max_length=100)\n content = models.TextField(_(\"content\"))\n\n # Donne ou non les droits d'\u00e9dition des propri\u00e9t\u00e9s de l'objet\n # Un utilisateur dans le bureau AE aura tous les droits sur cet objet\n def is_owned_by(self, user):\n return user.is_board_member\n\n # Donne ou non les droits d'\u00e9dition de l'objet\n # L'objet ne sera modifiable que par un utilisateur cotisant\n def can_be_edited_by(self, user):\n return user.is_subscribed\n\n # Donne ou non les droits de vue de l'objet\n # Ici, l'objet n'est visible que par un utilisateur connect\u00e9\n def can_be_viewed_by(self, user):\n return not user.is_anonymous\n
from django.db import models\nfrom django.conf import settings\nfrom django.utils.translation import gettext_lazy as _\n\nfrom core.models import User, Group\n\nclass Article(models.Model):\n title = models.CharField(_(\"title\"), max_length=100)\n content = models.TextField(_(\"content\"))\n\n # relation one-to-many\n # Groupe poss\u00e9dant l'objet\n # Donne les droits d'\u00e9dition des propri\u00e9t\u00e9s de l'objet\n owner_group = models.ForeignKey(\n Group, related_name=\"owned_articles\", default=settings.SITH_GROUP_ROOT_ID\n )\n\n # relation many-to-many\n # Tous les groupes qui seront ajout\u00e9 dans ce champ auront les droits d'\u00e9dition de l'objet\n edit_groups = models.ManyToManyField(\n Group,\n related_name=\"editable_articles\",\n verbose_name=_(\"edit groups\"),\n blank=True,\n )\n\n # relation many-to-many\n # Tous les groupes qui seront ajout\u00e9 dans ce champ auront les droits de vue de l'objet\n view_groups = models.ManyToManyField(\n Group,\n related_name=\"viewable_articles\",\n verbose_name=_(\"view groups\"),\n blank=True,\n )\n
"},{"location":"tutorial/perms/#appliquer-les-permissions","title":"Appliquer les permissions","text":""},{"location":"tutorial/perms/#dans-un-template","title":"Dans un template","text":"Il existe trois fonctions de base sur lesquelles reposent les v\u00e9rifications de permission. Elles sont disponibles dans le contexte par d\u00e9faut du moteur de template et peuvent \u00eatre utilis\u00e9es \u00e0 tout moment.
obj.is_owned_by(user)
obj.can_be_edited_by(user)
obj.can_be_viewed_by(user)
Voici un exemple d'utilisation dans un template :
{# ... #}\n{% if can_edit(club, user) %}\n <a href=\"{{ url('club:tools', club_id=club.id) }}\">{{ club }}</a>\n{% endif %}\n
"},{"location":"tutorial/perms/#dans-une-vue","title":"Dans une vue","text":"G\u00e9n\u00e9ralement, les v\u00e9rifications de droits dans les templates se limitent aux urls \u00e0 afficher puisqu'il ne faut normalement pas mettre de logique autre que d'affichage \u00e0 l'int\u00e9rieur (en r\u00e9alit\u00e9, c'est un principe qu'on a beaucoup viol\u00e9, mais promis on le fera plus). C'est donc habituellement au niveau des vues que cela a lieu.
Notre syst\u00e8me s'appuie sur un syst\u00e8me de mixin \u00e0 h\u00e9riter lors de la cr\u00e9ation d'une vue bas\u00e9e sur une classe. Ces mixins ne sont compatibles qu'avec les classes r\u00e9cup\u00e9rant un objet ou une liste d'objet. Dans le cas d'un seul objet, une permission refus\u00e9e est lev\u00e9e lorsque l'utilisateur n'a pas le droit de visionner la page. Dans le cas d'une liste d'objet, le mixin filtre les objets non autoris\u00e9s et si aucun ne l'est, l'utilisateur recevra une liste vide d'objet.
Voici un exemple d'utilisation en reprenant l'objet Article cr\u00e9e pr\u00e9c\u00e9demment :
from django.views.generic import CreateView, ListView\n\nfrom core.views import CanViewMixin, CanCreateMixin\n\nfrom com.models import WeekmailArticle\n\n# Il est important de mettre le mixin avant la classe h\u00e9rit\u00e9e de Django\n# L'h\u00e9ritage multiple se fait de droite \u00e0 gauche et les mixins ont besoin\n# d'une classe de base pour fonctionner correctement.\nclass ArticlesListView(CanViewMixin, ListView):\n model = WeekmailArticle\n\n\n# M\u00eame chose pour une vue de cr\u00e9ation de l'objet Article\nclass ArticlesCreateView(CanCreateMixin, CreateView):\n model = WeekmailArticle\n
Les mixins suivants sont impl\u00e9ment\u00e9s :
LoginRequiredMixin
, fourni par Django)Performance
Ce syst\u00e8me maison de permissions fonctionne et r\u00e9pond aux attentes de l'\u00e9poque de sa conception. Mais d'un point de vue performance, il est souvent plus que probl\u00e9matique. En effet, toutes les permissions sont dynamiquement calcul\u00e9es et n\u00e9cessitent plusieurs appels en base de donn\u00e9es qui ne se r\u00e9sument pas \u00e0 une \u00ab\u00a0simple\u00a0\u00bb jointure mais \u00e0 plusieurs requ\u00eates diff\u00e9rentes et difficiles \u00e0 optimiser. De plus, \u00e0 chaque calcul de permission, il est n\u00e9cessaire de recommencer tous les calculs depuis le d\u00e9but. La solution \u00e0 \u00e7a est de mettre du cache de session sur les tests effectu\u00e9s r\u00e9cemment, mais cela engendre son autre lot de probl\u00e8mes.
Sur une vue o\u00f9 on manipule un seul objet, passe encore. Mais sur les ListView
, on peut arriver \u00e0 des temps de r\u00e9ponse extr\u00eamement \u00e9lev\u00e9s.
L'API utilise son propre syst\u00e8me de permissions. Ce n'est pas encore un autre syst\u00e8me en parall\u00e8le, mais un wrapper autour de notre syst\u00e8me de permissions, afin de l'adapter aux besoins de l'API.
En effet, l'interface attendue pour manipuler le plus ais\u00e9ment possible les permissions des routes d'API avec la librairie que nous utilisons est diff\u00e9rente de notre syst\u00e8me, tout en restant adaptable. (Pour plus de d\u00e9tail, voir la doc de la lib).
Si vous avez bien suivi ce qui a \u00e9t\u00e9 dit plus haut, vous ne devriez pas \u00eatre perdu, \u00e9tant donn\u00e9 que le syst\u00e8me de permissions de l'API utilise des noms assez similaires : IsInGroup
, IsRoot
, IsSubscriber
... Vous pouvez trouver des exemples d'utilisation de ce syst\u00e8me dans cette partie.
Un projet Django est structur\u00e9 en applications. Une application est un package Python contenant un ensemble de vues, de mod\u00e8les, de templates, etc. S\u00e9mantiquement, une application repr\u00e9sente un ensemble de fonctionnalit\u00e9s coh\u00e9rentes. Par exemple, dans notre cas, nous avons une application charg\u00e9e de la gestion des comptoirs, une autre de la gestion des clubs, une autre de la gestion du SAS, etc.
On trouve g\u00e9n\u00e9ralement dans un projet Django une application principale qui contient les fichiers de configuration du projet, les urls et \u00e9ventuellement des commandes d'administration.
"},{"location":"tutorial/structure/#arborescence-du-projet","title":"Arborescence du projet","text":"Le code source du projet est organis\u00e9 comme suit :
sith/\n\u251c\u2500\u2500 .github/\n\u2502 \u251c\u2500\u2500 actions/ (1)\n\u2502 \u2514\u2500\u2500 workflows/ (2)\n\u251c\u2500\u2500 accounting/ (3)\n\u2502 \u2514\u2500\u2500 ...\n\u251c\u2500\u2500 club/ (4)\n\u2502 \u2514\u2500\u2500 ...\n\u251c\u2500\u2500 com/ (5)\n\u2502 \u2514\u2500\u2500 ...\n\u251c\u2500\u2500 core/ (6)\n\u2502 \u2514\u2500\u2500 ...\n\u251c\u2500\u2500 counter/ (7)\n\u2502 \u2514\u2500\u2500 ...\n\u251c\u2500\u2500 docs/ (8)\n\u2502 \u2514\u2500\u2500 ...\n\u251c\u2500\u2500 eboutic/ (9)\n\u2502 \u2514\u2500\u2500 ...\n\u251c\u2500\u2500 election/ (10)\n\u2502 \u2514\u2500\u2500 ...\n\u251c\u2500\u2500 forum/ (11)\n\u2502 \u2514\u2500\u2500 ...\n\u251c\u2500\u2500 galaxy/ (12)\n\u2502 \u2514\u2500\u2500 ...\n\u251c\u2500\u2500 launderette/ (13)\n\u2502 \u2514\u2500\u2500 ...\n\u251c\u2500\u2500 locale/ (14)\n\u2502 \u2514\u2500\u2500 ...\n\u251c\u2500\u2500 matmat/ (15)\n\u2502 \u2514\u2500\u2500 ...\n\u251c\u2500\u2500 pedagogy/ (16)\n\u2502 \u2514\u2500\u2500 ...\n\u251c\u2500\u2500 rootplace/ (17)\n\u2502 \u2514\u2500\u2500 ...\n\u251c\u2500\u2500 sas/ (18)\n\u2502 \u2514\u2500\u2500 ...\n\u251c\u2500\u2500 sith/ (19)\n\u2502 \u2514\u2500\u2500 ...\n\u251c\u2500\u2500 subscription/ (20)\n\u2502 \u2514\u2500\u2500 ...\n\u251c\u2500\u2500 trombi/ (21)\n\u2502 \u2514\u2500\u2500 ...\n\u251c\u2500\u2500 antispam/ (22)\n\u2502 \u2514\u2500\u2500 ...\n\u251c\u2500\u2500 staticfiles/ (23)\n\u2502 \u2514\u2500\u2500 ...\n\u2502\n\u251c\u2500\u2500 .coveragerc (24)\n\u251c\u2500\u2500 .envrc (25)\n\u251c\u2500\u2500 .gitattributes\n\u251c\u2500\u2500 .gitignore\n\u251c\u2500\u2500 .mailmap\n\u251c\u2500\u2500 .env.exemple\n\u251c\u2500\u2500 manage.py (26)\n\u251c\u2500\u2500 mkdocs.yml (27)\n\u251c\u2500\u2500 poetry.lock\n\u251c\u2500\u2500 pyproject.toml (28)\n\u2514\u2500\u2500 README.md\n
setup-project
installe poetry puis appelle la commande poetry install
.docs.yml
compile et publie la documentation \u00e0 chaque push sur la branche master
.python ./manage.py <nom de la commande>
L'application principale du projet est le package sith
. Ce package contient les fichiers de configuration du projet, la racine des urls.
Il est organis\u00e9 comme suit :
sith/\n\u251c\u2500\u2500 settings.py (1)\n\u251c\u2500\u2500 settings_custom.py (2)\n\u251c\u2500\u2500 toolbar_debug.py (3)\n\u251c\u2500\u2500 urls.py (4)\n\u2514\u2500\u2500 wsgi.py (5)\n
settings.py
.Les applications sont des packages Python. Dans ce projet, les applications sont g\u00e9n\u00e9ralement organis\u00e9es comme suit :
.\n\u251c\u2500\u2500 migrations/ (1)\n\u2502 \u2514\u2500\u2500 ...\n\u251c\u2500\u2500 templates/ (2)\n\u2502 \u2514\u2500\u2500 ...\n\u251c\u2500\u2500 static/ (3)\n\u2502 \u2514\u2500\u2500 webpack/ (4)\n\u2502 \u2514\u2500\u2500 ...\n\u251c\u2500\u2500 api.py (5)\n\u251c\u2500\u2500 admin.py (6)\n\u251c\u2500\u2500 models.py (7)\n\u251c\u2500\u2500 tests.py (8)\n\u251c\u2500\u2500 schemas.py (9)\n\u251c\u2500\u2500 urls.py (10)\n\u2514\u2500\u2500 views.py (11)\n
static/webpack
.views
dans lequel les vues sont r\u00e9parties entre plusieurs fichiers.L'organisation peut \u00e9ventuellement \u00eatre un peu diff\u00e9rente pour certaines applications, mais le principe g\u00e9n\u00e9ral est le m\u00eame.
"}]}