Skip to content
Snippets Groups Projects

Compare revisions

Changes are shown as if the source revision was being merged into the target revision. Learn more about comparing revisions.

Source

Select target project
No results found

Target

Select target project
  • bde/nk20
  • mcngnt/nk20
2 results
Show changes
Commits on Source (24)
Showing
with 451 additions and 21 deletions
......@@ -4,10 +4,14 @@
from django.contrib.contenttypes.models import ContentType
from django.contrib.auth.models import User
from rest_framework.serializers import ModelSerializer
from django.utils import timezone
from rest_framework import serializers
from member.api.serializers import ProfileSerializer, MembershipSerializer
from note.api.serializers import NoteSerializer
from note.models import Alias
class UserSerializer(ModelSerializer):
class UserSerializer(serializers.ModelSerializer):
"""
REST API Serializer for Users.
The djangorestframework plugin will analyse the model `User` and parse all fields in the API.
......@@ -22,7 +26,7 @@ class UserSerializer(ModelSerializer):
)
class ContentTypeSerializer(ModelSerializer):
class ContentTypeSerializer(serializers.ModelSerializer):
"""
REST API Serializer for Users.
The djangorestframework plugin will analyse the model `User` and parse all fields in the API.
......@@ -31,3 +35,42 @@ class ContentTypeSerializer(ModelSerializer):
class Meta:
model = ContentType
fields = '__all__'
class OAuthSerializer(serializers.ModelSerializer):
"""
Informations that are transmitted by OAuth.
For now, this includes user, profile and valid memberships.
This should be better managed later.
"""
normalized_name = serializers.SerializerMethodField()
profile = ProfileSerializer()
note = NoteSerializer()
memberships = serializers.SerializerMethodField()
def get_normalized_name(self, obj):
return Alias.normalize(obj.username)
def get_memberships(self, obj):
return serializers.ListSerializer(child=MembershipSerializer()).to_representation(
obj.memberships.filter(date_start__lte=timezone.now(), date_end__gte=timezone.now()))
class Meta:
model = User
fields = (
'id',
'username',
'normalized_name',
'first_name',
'last_name',
'email',
'is_superuser',
'is_active',
'is_staff',
'profile',
'note',
'memberships',
)
......@@ -3,6 +3,7 @@
import json
from datetime import datetime, date
from decimal import Decimal
from urllib.parse import quote_plus
from warnings import warn
......@@ -152,6 +153,8 @@ class TestAPI(TestCase):
value = value.isoformat()
elif isinstance(value, ImageFieldFile):
value = value.name
elif isinstance(value, Decimal):
value = str(value)
query = json.dumps({field.name: value})
# Create sample permission
......
......@@ -5,6 +5,7 @@ from django.conf import settings
from django.conf.urls import url, include
from rest_framework import routers
from .views import UserInformationView
from .viewsets import ContentTypeViewSet, UserViewSet
# Routers provide an easy way of automatically determining the URL conf.
......@@ -47,5 +48,6 @@ app_name = 'api'
# Additionally, we include login URLs for the browsable API.
urlpatterns = [
url('^', include(router.urls)),
url('^me/', UserInformationView.as_view()),
url('^api-auth/', include('rest_framework.urls', namespace='rest_framework')),
]
# Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from django.contrib.auth.models import User
from rest_framework.generics import RetrieveAPIView
from .serializers import OAuthSerializer
class UserInformationView(RetrieveAPIView):
"""
These fields are give to OAuth authenticators.
"""
serializer_class = OAuthSerializer
def get_queryset(self):
return User.objects.filter(pk=self.request.user.pk)
def get_object(self):
return self.request.user
# Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from cas_server.auth import DjangoAuthUser # pragma: no cover
from note.models import Alias
class CustomAuthUser(DjangoAuthUser): # pragma: no cover
"""
Override Django Auth User model to define a custom Matrix username.
"""
def attributs(self):
d = super().attributs()
if self.user:
d["normalized_name"] = Alias.normalize(self.user.username)
return d
# Generated by Django 2.2.19 on 2021-03-13 11:35
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('member', '0006_create_note_account_bde_membership'),
]
operations = [
migrations.AlterField(
model_name='membership',
name='roles',
field=models.ManyToManyField(related_name='memberships', to='permission.Role', verbose_name='roles'),
),
migrations.AlterField(
model_name='profile',
name='promotion',
field=models.PositiveSmallIntegerField(default=2021, help_text='Year of entry to the school (None if not ENS student)', null=True, verbose_name='promotion'),
),
]
# Generated by Django 2.2.19 on 2021-03-13 11:35
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('note', '0004_remove_null_tag_on_charfields'),
]
operations = [
migrations.AlterField(
model_name='alias',
name='note',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='alias', to='note.Note'),
),
]
......@@ -223,7 +223,8 @@ class Transaction(PolymorphicModel):
# Check that the amounts stay between big integer bounds
diff_source, diff_dest = self.validate()
if not self.source.is_active or not self.destination.is_active:
if not (hasattr(self, '_force_save') and self._force_save) \
and (not self.source.is_active or not self.destination.is_active):
raise ValidationError(_("The transaction can't be saved since the source note "
"or the destination note is not active."))
......@@ -271,7 +272,7 @@ class RecurrentTransaction(Transaction):
)
def clean(self):
if self.template.destination != self.destination:
if self.template.destination != self.destination and not (hasattr(self, '_force_save') and self._force_save):
raise ValidationError(
_("The destination of this transaction must equal to the destination of the template."))
return super().clean()
......
......@@ -43,4 +43,5 @@ def delete_transaction(instance, **_kwargs):
"""
if not hasattr(instance, "_no_signal"):
instance.valid = False
instance._force_save = True
instance.save()
......@@ -223,13 +223,14 @@ function consume (source, source_alias, dest, quantity, amount, reason, type, ca
const newBalance = source.balance - quantity * amount
if (newBalance <= -5000) {
addMsg(interpolate(gettext('Warning, the transaction from the note %s succeed, ' +
'but the emitter note %s is very negative.', [source_alias, source_alias])), 'danger', 30000)
'but the emitter note %s is very negative.'), [source_alias, source_alias]), 'danger', 30000)
} else if (newBalance < 0) {
addMsg(interpolate(gettext('Warning, the transaction from the note %s succeed, ' +
'but the emitter note %s is negative.', [source_alias, source_alias])), 'warning', 30000)
'but the emitter note %s is negative.'), [source_alias, source_alias]), 'warning', 30000)
}
if (source.membership && source.membership.date_end < new Date().toISOString()) {
addMsg(interpolate(gettext('Warning, the emitter note %s is no more a BDE member.', [source_alias])), 'danger', 30000)
addMsg(interpolate(gettext('Warning, the emitter note %s is no more a BDE member.'), [source_alias]),
'danger', 30000)
}
}
reset()
......
......@@ -134,8 +134,6 @@ class PermissionBackend(ModelBackend):
return False
sess = get_current_session()
if sess is not None and sess.session_key is None:
return False
if user_obj.is_superuser and sess.get("permission_mask", -1) >= 42:
return True
......
......@@ -3024,7 +3024,9 @@
24,
25,
26,
27
27,
30,
33
]
}
},
......@@ -3509,6 +3511,7 @@
56,
57,
58,
135,
137,
143,
147,
......@@ -3519,8 +3522,7 @@
176,
177,
180,
181,
182
181
]
}
},
......
Subproject commit dbe7bf65917df40b0ce476f357d04726e20b406f
Subproject commit 8ec7d68a169c1072aec427925f3bf2fd54eab5a3
# Generated by Django 2.2.19 on 2021-03-21 09:34
import django.core.validators
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('treasury', '0002_invoice_remove_png_extension'),
]
operations = [
migrations.AlterField(
model_name='product',
name='quantity',
field=models.DecimalField(decimal_places=2, max_digits=7, validators=[django.core.validators.MinValueValidator(0)], verbose_name='Quantity'),
),
migrations.AlterField(
model_name='specialtransactionproxy',
name='remittance',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, related_name='transaction_proxies', to='treasury.Remittance', verbose_name='Remittance'),
),
]
......@@ -5,6 +5,7 @@ from datetime import date
from django.contrib.auth.models import User
from django.core.exceptions import ValidationError
from django.core.validators import MinValueValidator
from django.db import models, transaction
from django.db.models import Q
from django.template.loader import render_to_string
......@@ -131,12 +132,15 @@ class Product(models.Model):
verbose_name=_("Designation"),
)
quantity = models.PositiveIntegerField(
verbose_name=_("Quantity")
quantity = models.DecimalField(
decimal_places=2,
max_digits=7,
verbose_name=_("Quantity"),
validators=[MinValueValidator(0)],
)
amount = models.IntegerField(
verbose_name=_("Unit price")
verbose_name=_("Unit price"),
)
@property
......@@ -381,9 +385,14 @@ class SogeCredit(models.Model):
tr.valid = True
tr.created_at = timezone.now()
tr.save()
self.credit_transaction.valid = False
self.credit_transaction.reason += " (invalide)"
self.credit_transaction.save()
if self.credit_transaction:
# If the soge credit is deleted while the user is not validated yet,
# there is not credit transaction.
# There is a credit transaction iff the user declares that no bank account
# was opened after the validation of the account.
self.credit_transaction.valid = False
self.credit_transaction.reason += " (invalide)"
self.credit_transaction.save()
super().delete(**kwargs)
class Meta:
......
{% load escape_tex %}
{% load escape_tex i18n %}
{% language fr %}
\documentclass[a4paper,11pt]{article}
\usepackage{fontspec}
......@@ -176,3 +177,4 @@ TVA non applicable, article 293 B du CGI.
\end{center}
\end{document}
{% endlanguage %}
# Generated by Django 2.2.19 on 2021-03-13 11:35
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('wei', '0001_initial'),
]
operations = [
migrations.AlterField(
model_name='weiclub',
name='year',
field=models.PositiveIntegerField(default=2021, unique=True, verbose_name='year'),
),
migrations.AlterField(
model_name='weiregistration',
name='information_json',
field=models.TextField(default='{}', help_text='Information about the registration (buses for old members, survey for the new members), encoded in JSON', verbose_name='registration information'),
),
]
Service d'Authentification Centralisé (CAS)
===========================================
Un `CAS <https://fr.wikipedia.org/wiki/Central_Authentication_Service>`_ est
déployé sur la Note Kfet. Il est accessible à l'adresse `<https://note.crans.org/cas/>`_.
Il a pour but uniquement d'authentifier les utilisateurs via la note et ne communique
que peu d'informations.
Configuration
-------------
Le serveur CAS utilisé est implémenté grâce au paquet ``django-cas-server``. Il peut être
installé soit par PIP soit sur une machine Debian via
``apt install python3-django-cas-server``.
On ajoute ensuite ``cas_server`` aux applications Django installées. On n'oublie pas ni
d'appliquer les migrations (``./manage.py migrate``) ni de collecter les fichiers
statiques (``./manage.py collectstatic``).
On enregistre les routes dans ``note_kfet/urls.py`` :
.. code:: python
urlpatterns.append(
path('cas/', include('cas_server.urls', namespace='cas_server'))
)
Le CAS est désormais déjà prêt à être utilisé. Toutefois, puisque l'on utilise un site
Django-admin personnalisé, on n'oublie pas d'enregistrer les pages d'administration :
.. code:: python
if "cas_server" in settings.INSTALLED_APPS:
from cas_server.admin import *
from cas_server.models import *
admin_site.register(ServicePattern, ServicePatternAdmin)
admin_site.register(FederatedIendityProvider, FederatedIendityProviderAdmin)
Enfin, on souhaite pouvoir fournir au besoin le pseudo normalisé. Pour cela, on crée une
classe dans ``member.auth`` :
.. code:: python
class CustomAuthUser(DjangoAuthUser):
def attributs(self):
d = super().attributs()
if self.user:
d["normalized_name"] = Alias.normalize(self.user.username)
return d
Puis on source ce fichier dans les paramètres :
.. code:: python
CAS_AUTH_CLASS = 'member.auth.CustomAuthUser'
Utilisation
-----------
Le service est accessible sur `<https://note.crans.org/cas/>`_. C'est ce lien qu'il faut
donner à votre application.
L'application doit néanmoins être autorisée à accéder au CAS. Pour cela, rendez-vous
dans Django-admin (`<https://note.crans.org/cas/admin/>`_), dans
``Service Central d'Authentification/Motifs de services``, ajoutez une nouvelle entrée.
Choisissez votre position favorite puis le nom de l'application.
Les champs importants sont les deux suivants :
* **Motif :** il s'agit d'une expression régulière qui doit reconnaitre le site voulu.
Par exemple, pour autoriser Belenios (`<https://belenios.crans.org>`_), on rentrera
le motif ``^https?://belenios\.crans\.org/.*$``.
* **Champ d'utilisateur :** C'est le pseudo que renverra le CAS. Par défaut, il s'agira
du nom de note principal, mais il arrive parfois que certains sites supportent mal
d'avoir des caractères UTF-8 dans le pseudo. C'est par exemple le cas de Belenios.
On rentrera alors ``normalized_name`` dans ce champ, qui correspond à la version
normalisée (sans accent ni espace ni aucun caractère non-ASCII) du pseudo, et qui
suffit à identifier une personne.
On peut également utiliser le ``Single log out`` si besoin.
Applications externes
=====================
.. toctree::
:maxdepth: 2
:caption: Applications externes
cas
oauth2
.. warning::
L'utilisation de la note par des services externes est actuellement en beta. Il est
fort à parier que cette utilisation sera revue et améliorée à l'avenir.
Puisque la Note Kfet recense tous les comptes des adhérents BDE, les clubs ont alors
la possibilité de développer leurs propres applications et de les interfacer avec la
note. De cette façon, chaque application peut authentifier ses utilisateurs via la note,
et récupérer leurs adhésion, leur nom de note afin d'éventuellement faire des transferts
via l'API.
Deux protocoles d'authentification sont implémentées :
* `CAS <cas>`_
* `OAuth2 <oauth2>`_
À ce jour, il n'y a pas encore d'exemple d'utilisation d'application qui utilise ce
mécanisme, mais on peut imaginer par exemple que la Mediatek ou l'AMAP implémentent
ces protocoles pour récupérer leurs adhérents.
OAuth2
======
L'authentification `OAuth2 <https://fr.wikipedia.org/wiki/OAuth>`_ est supportée par la
Note Kfet. Elle offre l'avantage non seulement d'identifier les utilisateurs, mais aussi
de transmettre des informations à un service tiers tels que des informations personnelles,
le solde de la note ou encore les adhésions de l'utilisateur, en l'avertissant sur
quelles données sont effectivement collectées.
.. danger::
L'implémentation actuelle ne permet pas de choisir quels droits on offre. Se connecter
par OAuth2 offre actuellement exactement les mêmes permissions que l'on n'aurait
normalement, avec le masque le plus haut, y compris en écriture.
Faites alors très attention lorsque vous vous connectez à un service tiers via OAuth2,
et contrôlez bien exactement ce que l'application fait de vos données, à savoir si
elle ignore bien tout ce dont elle n'a pas besoin.
À l'avenir, la fenêtre d'authentification pourra vous indiquer clairement quels
paramètres sont collectés.
Configuration du serveur
------------------------
On utilise ``django-oauth-toolkit``, qui peut être installé grâce à PIP ou bien via APT,
via le paquet ``python3-django-oauth-toolkit``.
On commence par ajouter ``oauth2_provider`` aux applications Django installées. On
n'oublie pas ni d'appliquer les migrations (``./manage.py migrate``) ni de collecter
les fichiers statiques (``./manage.py collectstatic``).
On souhaite que l'API gérée par ``django-rest-framework`` puisse être accessible via
l'authentification OAuth2. On adapte alors la configuration pour permettre cela :
.. code:: python
REST_FRAMEWORK = {
'DEFAULT_AUTHENTICATION_CLASSES': [
'rest_framework.authentication.SessionAuthentication',
'rest_framework.authentication.TokenAuthentication',
'oauth2_provider.contrib.rest_framework.OAuth2Authentication',
...
],
...
}
On ajoute les routes dans ``urls.py`` :
.. code:: python
urlpatterns.append(
path('o/', include('oauth2_provider.urls', namespace='oauth2_provider'))
)
L'OAuth2 est désormais prêt à être utilisé.
Configuration client
--------------------
Contrairement au `CAS <cas>`_, n'importe qui peut en théorie créer une application OAuth2.
En théorie, car pour l'instant les permissions ne leur permettent pas.
Pour créer une application, il faut se rendre à la page
`/o/applications/ <https://note.crans.org/o/applications/>`_. Dans ``client type``,
rentrez ``public`` (ou ``confidential`` selon vos choix), et vous rentrerez
généralement ``authorization-code`` dans ``Authorization Grant Type``.
Le champ ``Redirect Uris`` contient une liste d'adresses URL autorisées pour des
redirections post-connexion.
Il vous suffit de donner à votre application :
* L'identifiant client (client-ID)
* La clé secrète
* Les scopes : sous-ensemble de ``[read, write]`` (ignoré pour l'instant, cf premier paragraphe)
* L'URL d'autorisation : `<https://note.crans.org/o/authorize/>`_
* L'URL d'obtention de jeton : `<https://note.crans.org/o/token/>`_
* L'URL de récupération des informations de l'utilisateur : `<https://note.crans.org/api/me/>`_
N'hésitez pas à consulter la page `<https://note.crans.org/api/me/>`_ pour s'imprégner
du format renvoyé.
Avec Django-allauth
###################
Si vous utilisez Django-allauth pour votre propre application, vous pouvez utiliser
le module pré-configuré disponible ici :
`<https://gitlab.crans.org/bde/allauth-note-kfet>`_. Pour l'installer, vous
pouvez simplement faire :
.. code:: bash
$ pip3 install git+https://gitlab.crans.org/bde/allauth-note-kfet.git
L'installation du module se fera automatiquement.
Il vous suffit ensuite d'inclure l'application ``allauth_note_kfet`` à vos applications
installées (sur votre propre client), puis de bien ajouter l'application sociale :
.. code:: python
SOCIALACCOUNT_PROVIDERS = {
'notekfet': {
# 'DOMAIN': 'note.crans.org',
},
...
}
Le paramètre ``DOMAIN`` permet de changer d'instance de Note Kfet. Par défaut, il
se connectera à ``note.crans.org`` si vous ne renseignez rien.
En créant l'application sur la note, vous pouvez renseigner
``https://monsite.example.com/accounts/notekfet/login/callback/`` en URL de redirection,
à adapter selon votre configuration.
Vous devrez ensuite enregistrer l'application sociale dans la base de données.
Vous pouvez passer par Django-admin, mais cela peut nécessiter d'avoir déjà un compte,
alors autant le faire via un shell python :
.. code:: python
from allauth.socialaccount.models import SocialApp
SocialApp.objects.create(
name="Note Kfet",
provider="notekfet",
client_id="VOTRECLIENTID",
secret="VOTRESECRET",
key="",
)
Si vous avez bien configuré ``django-allauth``, vous êtes désormais prêts par à vous
connecter via la note :) Par défaut, nom, prénom, pseudo et adresse e-mail sont
récupérés. Les autres données sont stockées mais inutilisées.