Merge remote-tracking branch 'origin/master' into activity

# Conflicts:
#	note_kfet/urls.py
#	templates/base.html
This commit is contained in:
Yohann D'ANELLO 2020-03-27 00:32:22 +01:00
commit 841d56d5b3
482 changed files with 89737 additions and 1166 deletions

13
.env_example Normal file
View File

@ -0,0 +1,13 @@
DJANGO_APP_STAGE="dev"
# Only used in dev mode, change to "postgresql" if you want to use PostgreSQL in dev
DJANGO_DEV_STORE_METHOD="sqllite"
DJANGO_DB_HOST="localhost"
DJANGO_DB_NAME="note_db"
DJANGO_DB_USER="note"
DJANGO_DB_PASSWORD="CHANGE_ME"
DJANGO_DB_PORT=""
DJANGO_SECRET_KEY="CHANGE_ME"
DJANGO_SETTINGS_MODULE="note_kfet.settings"
DOMAIN="localhost"
CONTACT_EMAIL="tresorerie.bde@localhost"
NOTE_URL="localhost"

2
.gitignore vendored
View File

@ -37,7 +37,7 @@ coverage
# Local data
secrets.py
*.log
media/
# Virtualenv
env/
venv/

3
.gitmodules vendored Normal file
View File

@ -0,0 +1,3 @@
[submodule "apps/scripts"]
path = apps/scripts
url = git@gitlab.crans.org:bde/nk20-scripts.git

View File

@ -9,10 +9,18 @@ RUN apt update && \
apt install -y gettext nginx uwsgi uwsgi-plugin-python3 && \
rm -rf /var/lib/apt/lists/*
COPY requirements.txt /code/
RUN pip install -r requirements.txt
# Install LaTeX requirements
RUN apt update && \
apt install -y texlive-latex-extra texlive-fonts-extra texlive-lang-french && \
rm -rf /var/lib/apt/lists/*
COPY . /code/
# Comment what is not needed
RUN pip install -r requirements/base.txt
RUN pip install -r requirements/api.txt
RUN pip install -r requirements/cas.txt
RUN pip install -r requirements/production.txt
ENTRYPOINT ["/code/entrypoint.sh"]
EXPOSE 8000

View File

@ -6,13 +6,17 @@
## Installation sur un serveur
On supposera pour la suite que vous utiliser debian/ubuntu sur un serveur tout nu ou bien configuré.
On supposera pour la suite que vous utilisez Debian/Ubuntu sur un serveur tout nu ou bien configuré.
1. Paquets nécessaires
$ sudo apt install nginx python3 python3-pip python3-dev uwsgi
$ sudo apt install uwsgi-plugin-python3 python3-venv git acl
La génération des factures de l'application trésorerie nécessite une installation de LaTeX suffisante :
$ sudo apt install texlive-latex-extra texlive-fonts-extra texlive-lang-french
2. Clonage du dépot
on se met au bon endroit :
@ -31,7 +35,8 @@ On supposera pour la suite que vous utiliser debian/ubuntu sur un serveur tout n
$ python3 -m venv env
$ source env/bin/activate
(env)$ pip3 install -r requirements.txt
(env)$ pip3 install -r requirements/base.txt
(env)$ pip3 install -r requirements/prod.txt # uniquement en prod, nécessite un base postgres
(env)$ deactivate
4. uwsgi et Nginx
@ -40,13 +45,12 @@ On supposera pour la suite que vous utiliser debian/ubuntu sur un serveur tout n
$ cp nginx_note.conf_example nginx_note.conf
***Modifier le fichier pour être en accord avec le reste de votre config***
***Modifier le fichier pour être en accord avec le reste de votre config***
On utilise uwsgi et Nginx pour gérer le coté serveu :
On utilise uwsgi et Nginx pour gérer le coté serveur :
$ sudo ln -sf /var/www/note_kfet/nginx_note.conf /etc/nginx/sites-enabled/
Si l'on a un emperor (plusieurs instance uwsgi):
$ sudo ln -sf /var/www/note_kfet/uwsgi_note.ini /etc/uwsgi/sites/
@ -85,7 +89,7 @@ On supposera pour la suite que vous utiliser debian/ubuntu sur un serveur tout n
postgres=# CREATE DATABASE note_db OWNER note;
CREATE DATABASE
Si tout va bien:
Si tout va bien :
postgres=#\list
List of databases
@ -97,21 +101,28 @@ On supposera pour la suite que vous utiliser debian/ubuntu sur un serveur tout n
template1 | postgres | UTF8 | fr_FR.UTF-8 | fr_FR.UTF-8 | =c/postgres +postgres=CTc/postgres
(4 rows)
Dans un fichier `.env` à la racine du projet on renseigne des secrets:
DJANGO_APP_STAGE='prod'
DJANGO_DB_PASSWORD='le_mot_de_passe_de_la_bdd'
DJANGO_SECRET_KEY='une_secret_key_longue_et_compliquee'
ALLOWED_HOSTS='le_ndd_de_votre_instance'
6. Variable d'environnement et Migrations
On copie le fichier `.env_example` vers le fichier `.env` à la racine du projet
et on renseigne des secrets et des paramètres :
Ensuite on (re)bascule dans l'environement virtuel et on lance les migrations
DJANGO_APP_STAGE="dev" # ou "prod"
DJANGO_DEV_STORE_METHOD="sqllite" # ou "postgres"
DJANGO_DB_HOST="localhost"
DJANGO_DB_NAME="note_db"
DJANGO_DB_USER="note"
DJANGO_DB_PASSWORD="CHANGE_ME"
DJANGO_DB_PORT=""
DJANGO_SECRET_KEY="CHANGE_ME"
DJANGO_SETTINGS_MODULE="note_kfet.settings"
DOMAIN="localhost" # note.example.com
CONTACT_EMAIL="tresorerie.bde@localhost"
NOTE_URL="localhost" # serveur cas note.example.com si auto-hébergé.
Ensuite on (re)bascule dans l'environement virtuel et on lance les migrations
$ source /env/bin/activate
(env)$ ./manage.py check # pas de bétise qui traine
(env)$ ./manage.py check # pas de bêtise qui traine
(env)$ ./manage.py makemigrations
(env)$ ./manage.py migrate
@ -126,17 +137,21 @@ Il est possible de travailler sur une instance Docker.
$ git clone git@gitlab.crans.org:bde/nk20.git
2. Dans le fichier `docker_compose.yml`, qu'on suppose déjà configuré,
2. Copiez le fichier `.env_example` à la racine du projet vers le fichier `.env`,
et mettez à jour vos variables d'environnement
3. Dans le fichier `docker_compose.yml`, qu'on suppose déjà configuré,
ajouter les lignes suivantes, en les adaptant à la configuration voulue :
nk20:
build: /chemin/vers/nk20
volumes:
- /chemin/vers/nk20:/code/
env_file: /chemin/vers/nk20/.env
restart: always
labels:
- traefik.domain=ndd.exemple.com
- traefik.frontend.rule=Host:ndd.exemple.com
- traefik.domain=ndd.example.com
- traefik.frontend.rule=Host:ndd.example.com
- traefik.port=8000
3. Enjoy :
@ -157,19 +172,22 @@ un serveur de développement par exemple sur son ordinateur.
$ python3 -m venv venv
$ source venv/bin/activate
(env)$ pip install -r requirements.txt
(env)$ pip install -r requirements/base.txt
3. Migrations et chargement des données initiales :
3. Copier le fichier `.env_example` vers `.env` à la racine du projet et mettre à jour
ce qu'il faut
4. Migrations et chargement des données initiales :
(env)$ ./manage.py makemigrations
(env)$ ./manage.py migrate
(env)$ ./manage.py loaddata initial
4. Créer un super-utilisateur :
5. Créer un super-utilisateur :
(env)$ ./manage.py createsuperuser
5. Enjoy :
6. Enjoy :
(env)$ ./manage.py runserver 0.0.0.0:8000
@ -184,4 +202,4 @@ Il est disponible [ici](https://wiki.crans.org/NoteKfet/NoteKfet2018/CdC).
## Documentation
La documentation est générée par django et son module admindocs.
**Commenter votre code !**
**Commentez votre code !**

View File

@ -11,7 +11,7 @@ class ActivityAdmin(admin.ModelAdmin):
Admin customisation for Activity
"""
list_display = ('name', 'activity_type', 'organizer')
list_filter = ('activity_type', )
list_filter = ('activity_type',)
search_fields = ['name', 'organizer__name']
# Organize activities by start date

View File

@ -11,6 +11,7 @@ class ActivityTypeSerializer(serializers.ModelSerializer):
REST API Serializer for Activity types.
The djangorestframework plugin will analyse the model `ActivityType` and parse all fields in the API.
"""
class Meta:
model = ActivityType
fields = '__all__'
@ -21,6 +22,7 @@ class ActivitySerializer(serializers.ModelSerializer):
REST API Serializer for Activities.
The djangorestframework plugin will analyse the model `Activity` and parse all fields in the API.
"""
class Meta:
model = Activity
fields = '__all__'
@ -31,6 +33,7 @@ class GuestSerializer(serializers.ModelSerializer):
REST API Serializer for Guests.
The djangorestframework plugin will analyse the model `Guest` and parse all fields in the API.
"""
class Meta:
model = Guest
fields = '__all__'

View File

@ -1,13 +1,15 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from rest_framework import viewsets
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework.filters import SearchFilter
from api.viewsets import ReadProtectedModelViewSet
from ..models import ActivityType, Activity, Guest
from .serializers import ActivityTypeSerializer, ActivitySerializer, GuestSerializer
from ..models import ActivityType, Activity, Guest
class ActivityTypeViewSet(viewsets.ModelViewSet):
class ActivityTypeViewSet(ReadProtectedModelViewSet):
"""
REST API View set.
The djangorestframework plugin will get all `ActivityType` objects, serialize it to JSON with the given serializer,
@ -15,9 +17,11 @@ class ActivityTypeViewSet(viewsets.ModelViewSet):
"""
queryset = ActivityType.objects.all()
serializer_class = ActivityTypeSerializer
filter_backends = [DjangoFilterBackend]
filterset_fields = ['name', 'can_invite', ]
class ActivityViewSet(viewsets.ModelViewSet):
class ActivityViewSet(ReadProtectedModelViewSet):
"""
REST API View set.
The djangorestframework plugin will get all `Activity` objects, serialize it to JSON with the given serializer,
@ -25,9 +29,11 @@ class ActivityViewSet(viewsets.ModelViewSet):
"""
queryset = Activity.objects.all()
serializer_class = ActivitySerializer
filter_backends = [DjangoFilterBackend]
filterset_fields = ['name', 'description', 'activity_type', ]
class GuestViewSet(viewsets.ModelViewSet):
class GuestViewSet(ReadProtectedModelViewSet):
"""
REST API View set.
The djangorestframework plugin will get all `Guest` objects, serialize it to JSON with the given serializer,
@ -35,3 +41,5 @@ class GuestViewSet(viewsets.ModelViewSet):
"""
queryset = Guest.objects.all()
serializer_class = GuestSerializer
filter_backends = [SearchFilter]
search_fields = ['$name', ]

4
apps/api/__init__.py Normal file
View File

@ -0,0 +1,4 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
default_app_config = 'api.apps.APIConfig'

10
apps/api/apps.py Normal file
View File

@ -0,0 +1,10 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from django.apps import AppConfig
from django.utils.translation import gettext_lazy as _
class APIConfig(AppConfig):
name = 'api'
verbose_name = _('API')

View File

@ -3,10 +3,18 @@
from django.conf.urls import url, include
from django.contrib.auth.models import User
from rest_framework import routers, serializers, viewsets
from django.contrib.contenttypes.models import ContentType
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework import routers, serializers
from rest_framework.filters import SearchFilter
from rest_framework.viewsets import ReadOnlyModelViewSet
from activity.api.urls import register_activity_urls
from api.viewsets import ReadProtectedModelViewSet
from member.api.urls import register_members_urls
from note.api.urls import register_note_urls
from treasury.api.urls import register_treasury_urls
from logs.api.urls import register_logs_urls
from permission.api.urls import register_permission_urls
class UserSerializer(serializers.ModelSerializer):
@ -14,6 +22,7 @@ class UserSerializer(serializers.ModelSerializer):
REST API Serializer for Users.
The djangorestframework plugin will analyse the model `User` and parse all fields in the API.
"""
class Meta:
model = User
exclude = (
@ -23,7 +32,18 @@ class UserSerializer(serializers.ModelSerializer):
)
class UserViewSet(viewsets.ModelViewSet):
class ContentTypeSerializer(serializers.ModelSerializer):
"""
REST API Serializer for Users.
The djangorestframework plugin will analyse the model `User` and parse all fields in the API.
"""
class Meta:
model = ContentType
fields = '__all__'
class UserViewSet(ReadProtectedModelViewSet):
"""
REST API View set.
The djangorestframework plugin will get all `User` objects, serialize it to JSON with the given serializer,
@ -31,15 +51,33 @@ class UserViewSet(viewsets.ModelViewSet):
"""
queryset = User.objects.all()
serializer_class = UserSerializer
filter_backends = [DjangoFilterBackend, SearchFilter]
filterset_fields = ['id', 'username', 'first_name', 'last_name', 'email', 'is_superuser', 'is_staff', 'is_active', ]
search_fields = ['$username', '$first_name', '$last_name', ]
# This ViewSet is the only one that is accessible from all authenticated users!
class ContentTypeViewSet(ReadOnlyModelViewSet):
"""
REST API View set.
The djangorestframework plugin will get all `User` objects, serialize it to JSON with the given serializer,
then render it on /api/users/
"""
queryset = ContentType.objects.all()
serializer_class = ContentTypeSerializer
# Routers provide an easy way of automatically determining the URL conf.
# Register each app API router and user viewset
router = routers.DefaultRouter()
router.register('models', ContentTypeViewSet)
router.register('user', UserViewSet)
register_members_urls(router, 'members')
register_activity_urls(router, 'activity')
register_note_urls(router, 'note')
register_treasury_urls(router, 'treasury')
register_permission_urls(router, 'permission')
register_logs_urls(router, 'logs')
app_name = 'api'

31
apps/api/viewsets.py Normal file
View File

@ -0,0 +1,31 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from django.contrib.contenttypes.models import ContentType
from permission.backends import PermissionBackend
from rest_framework import viewsets
from note_kfet.middlewares import get_current_authenticated_user
class ReadProtectedModelViewSet(viewsets.ModelViewSet):
"""
Protect a ModelViewSet by filtering the objects that the user cannot see.
"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
model = ContentType.objects.get_for_model(self.serializer_class.Meta.model).model_class()
user = get_current_authenticated_user()
self.queryset = model.objects.filter(PermissionBackend.filter_queryset(user, model, "view"))
class ReadOnlyProtectedModelViewSet(viewsets.ReadOnlyModelViewSet):
"""
Protect a ReadOnlyModelViewSet by filtering the objects that the user cannot see.
"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
model = ContentType.objects.get_for_model(self.serializer_class.Meta.model).model_class()
user = get_current_authenticated_user()
self.queryset = model.objects.filter(PermissionBackend.filter_queryset(user, model, "view"))

4
apps/logs/__init__.py Normal file
View File

@ -0,0 +1,4 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
default_app_config = 'logs.apps.LogsConfig'

View File

View File

@ -0,0 +1,19 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from rest_framework import serializers
from ..models import Changelog
class ChangelogSerializer(serializers.ModelSerializer):
"""
REST API Serializer for Changelog types.
The djangorestframework plugin will analyse the model `Changelog` and parse all fields in the API.
"""
class Meta:
model = Changelog
fields = '__all__'
# noinspection PyProtectedMember
read_only_fields = [f.name for f in model._meta.get_fields()] # Changelogs are read-only protected

11
apps/logs/api/urls.py Normal file
View File

@ -0,0 +1,11 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from .views import ChangelogViewSet
def register_logs_urls(router, path):
"""
Configure router for Activity REST API.
"""
router.register(path, ChangelogViewSet)

23
apps/logs/api/views.py Normal file
View File

@ -0,0 +1,23 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework.filters import OrderingFilter
from api.viewsets import ReadOnlyProtectedModelViewSet
from .serializers import ChangelogSerializer
from ..models import Changelog
class ChangelogViewSet(ReadOnlyProtectedModelViewSet):
"""
REST API View set.
The djangorestframework plugin will get all `Changelog` objects, serialize it to JSON with the given serializer,
then render it on /api/logs/
"""
queryset = Changelog.objects.all()
serializer_class = ChangelogSerializer
filter_backends = [DjangoFilterBackend, OrderingFilter]
filterset_fields = ['model', 'action', "instance_pk", 'user', 'ip', ]
ordering_fields = ['timestamp', ]
ordering = ['-timestamp', ]

18
apps/logs/apps.py Normal file
View File

@ -0,0 +1,18 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from django.apps import AppConfig
from django.db.models.signals import pre_save, post_save, post_delete
from django.utils.translation import gettext_lazy as _
class LogsConfig(AppConfig):
name = 'logs'
verbose_name = _('Logs')
def ready(self):
# noinspection PyUnresolvedReferences
from . import signals
pre_save.connect(signals.pre_save_object)
post_save.connect(signals.save_object)
post_delete.connect(signals.delete_object)

View File

77
apps/logs/models.py Normal file
View File

@ -0,0 +1,77 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from django.conf import settings
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError
from django.db import models
from django.utils.translation import gettext_lazy as _
class Changelog(models.Model):
"""
Store each modification in the database (except sessions and logging),
including creating, editing and deleting models.
"""
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.PROTECT,
null=True,
verbose_name=_('user'),
)
ip = models.GenericIPAddressField(
null=True,
blank=True,
verbose_name=_("IP Address")
)
model = models.ForeignKey(
ContentType,
on_delete=models.PROTECT,
null=False,
blank=False,
verbose_name=_('model'),
)
instance_pk = models.CharField(
max_length=255,
null=False,
blank=False,
verbose_name=_('identifier'),
)
previous = models.TextField(
null=True,
verbose_name=_('previous data'),
)
data = models.TextField(
null=True,
verbose_name=_('new data'),
)
action = models.CharField( # create, edit or delete
max_length=16,
null=False,
blank=False,
choices=[
('create', _('create')),
('edit', _('edit')),
('delete', _('delete')),
],
default='edit',
verbose_name=_('action'),
)
timestamp = models.DateTimeField(
null=False,
blank=False,
auto_now_add=True,
name='timestamp',
verbose_name=_('timestamp'),
)
def delete(self, using=None, keep_parents=False):
raise ValidationError(_("Logs cannot be destroyed."))

140
apps/logs/signals.py Normal file
View File

@ -0,0 +1,140 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from django.contrib.contenttypes.models import ContentType
from rest_framework.renderers import JSONRenderer
from rest_framework.serializers import ModelSerializer
from note.models import NoteUser, Alias
from note_kfet.middlewares import get_current_authenticated_user, get_current_ip
from .models import Changelog
import getpass
# Ces modèles ne nécessitent pas de logs
EXCLUDED = [
'admin.logentry',
'authtoken.token',
'cas_server.proxygrantingticket',
'cas_server.proxyticket',
'cas_server.serviceticket',
'cas_server.user',
'cas_server.userattributes',
'contenttypes.contenttype',
'logs.changelog', # Never remove this line
'migrations.migration',
'note.note' # We only store the subclasses
'note.transaction',
'sessions.session',
]
def pre_save_object(sender, instance, **kwargs):
"""
Before a model get saved, we get the previous instance that is currently in the database
"""
qs = sender.objects.filter(pk=instance.pk).all()
if qs.exists():
instance._previous = qs.get()
else:
instance._previous = None
def save_object(sender, instance, **kwargs):
"""
Each time a model is saved, an entry in the table `Changelog` is added in the database
in order to store each modification made
"""
# noinspection PyProtectedMember
if instance._meta.label_lower in EXCLUDED:
return
# noinspection PyProtectedMember
previous = instance._previous
# Si un utilisateur est connecté, on récupère l'utilisateur courant ainsi que son adresse IP
user, ip = get_current_authenticated_user(), get_current_ip()
if user is None:
# Si la modification n'a pas été faite via le client Web, on suppose que c'est du à `manage.py`
# On récupère alors l'utilisateur·trice connecté·e à la VM, et on récupère la note associée
# IMPORTANT : l'utilisateur dans la VM doit être un des alias note du respo info
ip = "127.0.0.1"
username = Alias.normalize(getpass.getuser())
note = NoteUser.objects.filter(alias__normalized_name=username)
# if not note.exists():
# print("WARNING: A model attempted to be saved in the DB, but the actor is unknown: " + username)
# else:
if note.exists():
user = note.get().user
# noinspection PyProtectedMember
if user is not None and instance._meta.label_lower == "auth.user" and previous:
# On n'enregistre pas les connexions
if instance.last_login != previous.last_login:
return
# On crée notre propre sérialiseur JSON pour pouvoir sauvegarder les modèles
class CustomSerializer(ModelSerializer):
class Meta:
model = instance.__class__
fields = '__all__'
previous_json = JSONRenderer().render(CustomSerializer(previous).data).decode("UTF-8") if previous else None
instance_json = JSONRenderer().render(CustomSerializer(instance).data).decode("UTF-8")
if previous_json == instance_json:
# Pas de log s'il n'y a pas de modification
return
Changelog.objects.create(user=user,
ip=ip,
model=ContentType.objects.get_for_model(instance),
instance_pk=instance.pk,
previous=previous_json,
data=instance_json,
action=("edit" if previous else "create")
).save()
def delete_object(sender, instance, **kwargs):
"""
Each time a model is deleted, an entry in the table `Changelog` is added in the database
"""
# noinspection PyProtectedMember
if instance._meta.label_lower in EXCLUDED:
return
# Si un utilisateur est connecté, on récupère l'utilisateur courant ainsi que son adresse IP
user, ip = get_current_authenticated_user(), get_current_ip()
if user is None:
# Si la modification n'a pas été faite via le client Web, on suppose que c'est du à `manage.py`
# On récupère alors l'utilisateur·trice connecté·e à la VM, et on récupère la note associée
# IMPORTANT : l'utilisateur dans la VM doit être un des alias note du respo info
ip = "127.0.0.1"
username = Alias.normalize(getpass.getuser())
note = NoteUser.objects.filter(alias__normalized_name=username)
# if not note.exists():
# print("WARNING: A model attempted to be saved in the DB, but the actor is unknown: " + username)
# else:
if note.exists():
user = note.get().user
# On crée notre propre sérialiseur JSON pour pouvoir sauvegarder les modèles
class CustomSerializer(ModelSerializer):
class Meta:
model = instance.__class__
fields = '__all__'
instance_json = JSONRenderer().render(CustomSerializer(instance).data).decode("UTF-8")
Changelog.objects.create(user=user,
ip=ip,
model=ContentType.objects.get_for_model(instance),
instance_pk=instance.pk,
previous=instance_json,
data=None,
action="delete"
).save()

View File

@ -18,9 +18,9 @@ class ProfileInline(admin.StackedInline):
class CustomUserAdmin(UserAdmin):
inlines = (ProfileInline, )
inlines = (ProfileInline,)
list_display = ('username', 'email', 'first_name', 'last_name', 'is_staff')
list_select_related = ('profile', )
list_select_related = ('profile',)
form = ProfileForm
def get_inline_instances(self, request, obj=None):

View File

@ -11,9 +11,11 @@ class ProfileSerializer(serializers.ModelSerializer):
REST API Serializer for Profiles.
The djangorestframework plugin will analyse the model `Profile` and parse all fields in the API.
"""
class Meta:
model = Profile
fields = '__all__'
read_only_fields = ('user', )
class ClubSerializer(serializers.ModelSerializer):
@ -21,6 +23,7 @@ class ClubSerializer(serializers.ModelSerializer):
REST API Serializer for Clubs.
The djangorestframework plugin will analyse the model `Club` and parse all fields in the API.
"""
class Meta:
model = Club
fields = '__all__'
@ -31,6 +34,7 @@ class RoleSerializer(serializers.ModelSerializer):
REST API Serializer for Roles.
The djangorestframework plugin will analyse the model `Role` and parse all fields in the API.
"""
class Meta:
model = Role
fields = '__all__'
@ -41,6 +45,7 @@ class MembershipSerializer(serializers.ModelSerializer):
REST API Serializer for Memberships.
The djangorestframework plugin will analyse the model `Memberships` and parse all fields in the API.
"""
class Meta:
model = Membership
fields = '__all__'

View File

@ -1,13 +1,14 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from rest_framework import viewsets
from rest_framework.filters import SearchFilter
from api.viewsets import ReadProtectedModelViewSet
from ..models import Profile, Club, Role, Membership
from .serializers import ProfileSerializer, ClubSerializer, RoleSerializer, MembershipSerializer
from ..models import Profile, Club, Role, Membership
class ProfileViewSet(viewsets.ModelViewSet):
class ProfileViewSet(ReadProtectedModelViewSet):
"""
REST API View set.
The djangorestframework plugin will get all `Profile` objects, serialize it to JSON with the given serializer,
@ -17,7 +18,7 @@ class ProfileViewSet(viewsets.ModelViewSet):
serializer_class = ProfileSerializer
class ClubViewSet(viewsets.ModelViewSet):
class ClubViewSet(ReadProtectedModelViewSet):
"""
REST API View set.
The djangorestframework plugin will get all `Club` objects, serialize it to JSON with the given serializer,
@ -25,9 +26,11 @@ class ClubViewSet(viewsets.ModelViewSet):
"""
queryset = Club.objects.all()
serializer_class = ClubSerializer
filter_backends = [SearchFilter]
search_fields = ['$name', ]
class RoleViewSet(viewsets.ModelViewSet):
class RoleViewSet(ReadProtectedModelViewSet):
"""
REST API View set.
The djangorestframework plugin will get all `Role` objects, serialize it to JSON with the given serializer,
@ -35,9 +38,11 @@ class RoleViewSet(viewsets.ModelViewSet):
"""
queryset = Role.objects.all()
serializer_class = RoleSerializer
filter_backends = [SearchFilter]
search_fields = ['$name', ]
class MembershipViewSet(viewsets.ModelViewSet):
class MembershipViewSet(ReadProtectedModelViewSet):
"""
REST API View set.
The djangorestframework plugin will get all `Membership` objects, serialize it to JSON with the given serializer,

View File

@ -2,9 +2,22 @@
# SPDX-License-Identifier: GPL-3.0-or-later
from django.apps import AppConfig
from django.conf import settings
from django.db.models.signals import post_save
from django.utils.translation import gettext_lazy as _
from .signals import save_user_profile
class MemberConfig(AppConfig):
name = 'member'
verbose_name = _('member')
def ready(self):
"""
Define app internal signals to interact with other apps
"""
post_save.connect(
save_user_profile,
sender=settings.AUTH_USER_MODEL,
)

View File

@ -1,11 +1,11 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from django_filters import FilterSet, CharFilter
from django.contrib.auth.models import User
from django.db.models import CharField
from crispy_forms.helper import FormHelper
from crispy_forms.layout import Layout, Submit
from django.contrib.auth.models import User
from django.db.models import CharField
from django_filters import FilterSet, CharFilter
class UserFilter(FilterSet):

View File

@ -5,7 +5,7 @@
"fields": {
"name": "BDE",
"email": "tresorerie.bde@example.com",
"membership_fee": 5,
"membership_fee": 500,
"membership_duration": "396 00:00:00",
"membership_start": "213 00:00:00",
"membership_end": "273 00:00:00"
@ -17,7 +17,7 @@
"fields": {
"name": "Kfet",
"email": "tresorerie.bde@example.com",
"membership_fee": 35,
"membership_fee": 3500,
"membership_duration": "396 00:00:00",
"membership_start": "213 00:00:00",
"membership_end": "273 00:00:00"

View File

@ -1,23 +1,31 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from crispy_forms.bootstrap import Div
from crispy_forms.helper import FormHelper
from crispy_forms.layout import Layout
from dal import autocomplete
from django.contrib.auth.forms import UserCreationForm
from django.contrib.auth.models import User
from django import forms
from django.contrib.auth.forms import UserCreationForm, AuthenticationForm
from django.contrib.auth.models import User
from permission.models import PermissionMask
from .models import Profile, Club, Membership
from crispy_forms.helper import FormHelper
from crispy_forms.bootstrap import Div
from crispy_forms.layout import Layout
class CustomAuthenticationForm(AuthenticationForm):
permission_mask = forms.ModelChoiceField(
label="Masque de permissions",
queryset=PermissionMask.objects.order_by("rank"),
empty_label=None,
)
class SignUpForm(UserCreationForm):
def __init__(self,*args,**kwargs):
super().__init__(*args,**kwargs)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['username'].widget.attrs.pop("autofocus", None)
self.fields['first_name'].widget.attrs.update({"autofocus":"autofocus"})
self.fields['first_name'].widget.attrs.update({"autofocus": "autofocus"})
class Meta:
model = User
@ -28,6 +36,7 @@ class ProfileForm(forms.ModelForm):
"""
A form for the extras field provided by the :model:`member.Profile` model.
"""
class Meta:
model = Profile
fields = '__all__'
@ -42,7 +51,7 @@ class ClubForm(forms.ModelForm):
class AddMembersForm(forms.Form):
class Meta:
fields = ('', )
fields = ('',)
class MembershipForm(forms.ModelForm):

View File

@ -1,10 +1,12 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
import datetime
from django.conf import settings
from django.db import models
from django.utils.translation import gettext_lazy as _
from django.urls import reverse, reverse_lazy
from django.utils.translation import gettext_lazy as _
class Profile(models.Model):
@ -46,9 +48,10 @@ class Profile(models.Model):
class Meta:
verbose_name = _('user profile')
verbose_name_plural = _('user profile')
indexes = [models.Index(fields=['user'])]
def get_absolute_url(self):
return reverse('user_detail', args=(self.pk, ))
return reverse('user_detail', args=(self.pk,))
class Club(models.Model):
@ -97,7 +100,7 @@ class Club(models.Model):
return self.name
def get_absolute_url(self):
return reverse_lazy('member:club_detail', args=(self.pk, ))
return reverse_lazy('member:club_detail', args=(self.pk,))
class Role(models.Model):
@ -149,16 +152,13 @@ class Membership(models.Model):
verbose_name=_('fee'),
)
def valid(self):
if self.date_end is not None:
return self.date_start.toordinal() <= datetime.datetime.now().toordinal() < self.date_end.toordinal()
else:
return self.date_start.toordinal() <= datetime.datetime.now().toordinal()
class Meta:
verbose_name = _('membership')
verbose_name_plural = _('memberships')
# @receiver(post_save, sender=settings.AUTH_USER_MODEL)
# def save_user_profile(instance, created, **_kwargs):
# """
# Hook to save an user profile when an user is updated
# """
# if created:
# Profile.objects.create(user=instance)
# instance.profile.save()
indexes = [models.Index(fields=['user'])]

View File

@ -1,2 +1,16 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
def save_user_profile(instance, created, raw, **_kwargs):
"""
Hook to create and save a profile when an user is updated if it is not registered with the signup form
"""
if raw:
# When provisionning data, do not try to autocreate
return
if created:
from .models import Profile
Profile.objects.get_or_create(user=instance)
instance.profile.save()

View File

@ -17,6 +17,7 @@ class ClubTable(tables.Table):
fields = ('id', 'name', 'email')
row_attrs = {
'class': 'table-row',
'id': lambda record: "row-" + str(record.pk),
'data-href': lambda record: record.pk
}

View File

@ -12,11 +12,15 @@ urlpatterns = [
path('club/<int:pk>/', views.ClubDetailView.as_view(), name="club_detail"),
path('club/<int:pk>/add_member/', views.ClubAddMemberView.as_view(), name="club_add_member"),
path('club/create/', views.ClubCreateView.as_view(), name="club_create"),
path('club/<int:pk>/update', views.ClubUpdateView.as_view(), name="club_update"),
path('club/<int:pk>/update_pic', views.ClubPictureUpdateView.as_view(), name="club_update_pic"),
path('user/', views.UserListView.as_view(), name="user_list"),
path('user/<int:pk>', views.UserDetailView.as_view(), name="user_detail"),
path('user/<int:pk>/update', views.UserUpdateView.as_view(), name="user_update_profile"),
path('user/<int:pk>/update_pic', views.ProfilePictureUpdateView.as_view(), name="user_update_pic"),
path('user/<int:pk>/aliases', views.AliasView.as_view(), name="user_alias"),
path('user/aliases/delete/<int:pk>', views.DeleteAliasView.as_view(), name="user_alias_delete"),
path('manage-auth-token/', views.ManageAuthTokens.as_view(), name='auth_token'),
# API for the user autocompleter
path('user/user-autocomplete', views.UserAutocomplete.as_view(), name="user_autocomplete"),
]

View File

@ -1,24 +1,44 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
import io
from PIL import Image
from dal import autocomplete
from django.conf import settings
from django.contrib import messages
from django.contrib.auth.mixins import LoginRequiredMixin
from django.shortcuts import redirect
from django.utils.translation import gettext_lazy as _
from django.views.generic import CreateView, DetailView, UpdateView, TemplateView
from django.contrib.auth.models import User
from django.urls import reverse_lazy
from django.contrib.auth.views import LoginView
from django.core.exceptions import ValidationError
from django.db.models import Q
from django.http import HttpResponseRedirect
from django.shortcuts import redirect
from django.urls import reverse_lazy
from django.utils.translation import gettext_lazy as _
from django.views.generic import CreateView, DetailView, UpdateView, TemplateView, DeleteView
from django.views.generic.edit import FormMixin
from django_tables2.views import SingleTableView
from rest_framework.authtoken.models import Token
from note.forms import AliasForm, ImageForm
from note.models import Alias, NoteUser
from note.models.transactions import Transaction
from note.tables import HistoryTable
from note.tables import HistoryTable, AliasTable
from permission.backends import PermissionBackend
from .models import Profile, Club, Membership
from .forms import SignUpForm, ProfileForm, ClubForm, MembershipForm, MemberFormSet, FormSetHelper
from .tables import ClubTable, UserTable
from .filters import UserFilter, UserFilterFormHelper
from .forms import SignUpForm, ProfileForm, ClubForm, MembershipForm, MemberFormSet, FormSetHelper, \
CustomAuthenticationForm
from .models import Club, Membership
from .tables import ClubTable, UserTable
class CustomLoginView(LoginView):
form_class = CustomAuthenticationForm
def form_valid(self, form):
self.request.session['permission_mask'] = form.cleaned_data['permission_mask'].rank
return super().form_valid(form)
class UserCreateView(CreateView):
@ -40,10 +60,10 @@ class UserCreateView(CreateView):
def form_valid(self, form):
profile_form = ProfileForm(self.request.POST)
if form.is_valid() and profile_form.is_valid():
user = form.save()
profile = profile_form.save(commit=False)
profile.user = user
profile.save()
user = form.save(commit=False)
user.profile = profile_form.save(commit=False)
user.save()
user.profile.save()
return super().form_valid(form)
@ -52,30 +72,25 @@ class UserUpdateView(LoginRequiredMixin, UpdateView):
fields = ['first_name', 'last_name', 'username', 'email']
template_name = 'member/profile_update.html'
context_object_name = 'user_object'
second_form = ProfileForm
profile_form = ProfileForm
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["profile_form"] = self.second_form(
instance=context['user_object'].profile)
context['profile_form'] = self.profile_form(instance=context['user_object'].profile)
context['title'] = _("Update Profile")
return context
def get_form(self, form_class=None):
form = super().get_form(form_class)
if 'username' not in form.data:
return form
new_username = form.data['username']
# Si l'utilisateur cherche à modifier son pseudo, le nouveau pseudo ne doit pas être proche d'un alias existant
note = NoteUser.objects.filter(
alias__normalized_name=Alias.normalize(new_username))
if note.exists() and note.get().user != self.object:
form.add_error('username',
_("An alias with a similar name already exists."))
return form
def form_valid(self, form):
@ -105,7 +120,7 @@ class UserUpdateView(LoginRequiredMixin, UpdateView):
return reverse_lazy('member:user_detail',
kwargs={'pk': kwargs['id']})
else:
return reverse_lazy('member:user_detail', args=(self.object.id, ))
return reverse_lazy('member:user_detail', args=(self.object.id,))
class UserDetailView(LoginRequiredMixin, DetailView):
@ -116,11 +131,14 @@ class UserDetailView(LoginRequiredMixin, DetailView):
context_object_name = "user_object"
template_name = "member/profile_detail.html"
def get_queryset(self, **kwargs):
return super().get_queryset().filter(PermissionBackend.filter_queryset(self.request.user, User, "view"))
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
user = context['user_object']
history_list = \
Transaction.objects.all().filter(Q(source=user.note) | Q(destination=user.note))
Transaction.objects.all().filter(Q(source=user.note) | Q(destination=user.note)).order_by("-id")
context['history_list'] = HistoryTable(history_list)
club_list = \
Membership.objects.all().filter(user=user).only("club")
@ -143,7 +161,7 @@ class UserListView(LoginRequiredMixin, SingleTableView):
formhelper_class = UserFilterFormHelper
def get_queryset(self, **kwargs):
qs = super().get_queryset()
qs = super().get_queryset().filter(PermissionBackend.filter_queryset(self.request.user, User, "view"))
self.filter = self.filter_class(self.request.GET, queryset=qs)
self.filter.form.helper = self.formhelper_class()
return self.filter.qs
@ -154,6 +172,110 @@ class UserListView(LoginRequiredMixin, SingleTableView):
return context
class AliasView(LoginRequiredMixin, FormMixin, DetailView):
model = User
template_name = 'member/profile_alias.html'
context_object_name = 'user_object'
form_class = AliasForm
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
note = context['user_object'].note
context["aliases"] = AliasTable(note.alias_set.all())
return context
def get_success_url(self):
return reverse_lazy('member:user_alias', kwargs={'pk': self.object.id})
def post(self, request, *args, **kwargs):
self.object = self.get_object()
form = self.get_form()
if form.is_valid():
return self.form_valid(form)
else:
return self.form_invalid(form)
def form_valid(self, form):
alias = form.save(commit=False)
alias.note = self.object.note
alias.save()
return super().form_valid(form)
class DeleteAliasView(LoginRequiredMixin, DeleteView):
model = Alias
def delete(self, request, *args, **kwargs):
try:
self.object = self.get_object()
self.object.delete()
except ValidationError as e:
# TODO: pass message to redirected view.
messages.error(self.request, str(e))
else:
messages.success(self.request, _("Alias successfully deleted"))
return HttpResponseRedirect(self.get_success_url())
def get_success_url(self):
return reverse_lazy('member:user_alias', kwargs={'pk': self.object.note.user.pk})
def get(self, request, *args, **kwargs):
return self.post(request, *args, **kwargs)
class PictureUpdateView(LoginRequiredMixin, FormMixin, DetailView):
form_class = ImageForm
def get_context_data(self, *args, **kwargs):
context = super().get_context_data(*args, **kwargs)
context['form'] = self.form_class(self.request.POST, self.request.FILES)
return context
def get_success_url(self):
return reverse_lazy('member:user_detail', kwargs={'pk': self.object.id})
def post(self, request, *args, **kwargs):
form = self.get_form()
self.object = self.get_object()
if form.is_valid():
return self.form_valid(form)
else:
print('is_invalid')
print(form)
return self.form_invalid(form)
def form_valid(self, form):
image_field = form.cleaned_data['image']
x = form.cleaned_data['x']
y = form.cleaned_data['y']
w = form.cleaned_data['width']
h = form.cleaned_data['height']
# image crop and resize
image_file = io.BytesIO(image_field.read())
# ext = image_field.name.split('.')[-1].lower()
# TODO: support GIF format
image = Image.open(image_file)
image = image.crop((x, y, x + w, y + h))
image_clean = image.resize((settings.PIC_WIDTH,
settings.PIC_RATIO * settings.PIC_WIDTH),
Image.ANTIALIAS)
image_file = io.BytesIO()
image_clean.save(image_file, "PNG")
image_field.file = image_file
# renaming
filename = "{}_pic.png".format(self.object.note.pk)
image_field.name = filename
self.object.note.display_image = image_field
self.object.note.save()
return super().form_valid(form)
class ProfilePictureUpdateView(PictureUpdateView):
model = User
template_name = 'member/profile_picture_update.html'
context_object_name = 'user_object'
class ManageAuthTokens(LoginRequiredMixin, TemplateView):
"""
Affiche le jeton d'authentification, et permet de le regénérer
@ -181,6 +303,7 @@ class UserAutocomplete(autocomplete.Select2QuerySetView):
"""
Auto complete users by usernames
"""
def get_queryset(self):
"""
Quand une personne cherche un utilisateur par pseudo, une requête est envoyée sur l'API dédiée à l'auto-complétion.
@ -190,10 +313,10 @@ class UserAutocomplete(autocomplete.Select2QuerySetView):
if not self.request.user.is_authenticated:
return User.objects.none()
qs = User.objects.all()
qs = User.objects.filter(PermissionBackend.filter_queryset(self.request.user, User, "view")).all()
if self.q:
qs = qs.filter(username__regex=self.q)
qs = qs.filter(username__regex="^" + self.q)
return qs
@ -209,6 +332,7 @@ class ClubCreateView(LoginRequiredMixin, CreateView):
"""
model = Club
form_class = ClubForm
success_url = reverse_lazy('member:club_list')
def form_valid(self, form):
return super().form_valid(form)
@ -221,11 +345,17 @@ class ClubListView(LoginRequiredMixin, SingleTableView):
model = Club
table_class = ClubTable
def get_queryset(self, **kwargs):
return super().get_queryset().filter(PermissionBackend.filter_queryset(self.request.user, Club, "view"))
class ClubDetailView(LoginRequiredMixin, DetailView):
model = Club
context_object_name = "club"
def get_queryset(self, **kwargs):
return super().get_queryset().filter(PermissionBackend.filter_queryset(self.request.user, Club, "view"))
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
club = context["club"]
@ -239,11 +369,33 @@ class ClubDetailView(LoginRequiredMixin, DetailView):
return context
class ClubUpdateView(LoginRequiredMixin, UpdateView):
model = Club
context_object_name = "club"
form_class = ClubForm
template_name = "member/club_form.html"
success_url = reverse_lazy("member:club_detail")
class ClubPictureUpdateView(PictureUpdateView):
model = Club
template_name = 'member/club_picture_update.html'
context_object_name = 'club'
def get_success_url(self):
return reverse_lazy('member:club_detail', kwargs={'pk': self.object.id})
class ClubAddMemberView(LoginRequiredMixin, CreateView):
model = Membership
form_class = MembershipForm
template_name = 'member/add_members.html'
def get_queryset(self, **kwargs):
return super().get_queryset().filter(PermissionBackend.filter_queryset(self.request.user, Membership, "view")
| PermissionBackend.filter_queryset(self.request.user, Membership,
"change"))
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['formset'] = MemberFormSet()

View File

@ -8,7 +8,7 @@ from polymorphic.admin import PolymorphicChildModelAdmin, \
from .models.notes import Alias, Note, NoteClub, NoteSpecial, NoteUser
from .models.transactions import Transaction, TemplateCategory, TransactionTemplate, \
TemplateTransaction, MembershipTransaction
RecurrentTransaction, MembershipTransaction
class AliasInlines(admin.TabularInline):
@ -47,11 +47,11 @@ class NoteClubAdmin(PolymorphicChildModelAdmin):
"""
Child for a club note, see NoteAdmin
"""
inlines = (AliasInlines, )
inlines = (AliasInlines,)
# We can't change club after creation or the balance
readonly_fields = ('club', 'balance')
search_fields = ('club', )
search_fields = ('club',)
def has_add_permission(self, request):
"""
@ -71,7 +71,7 @@ class NoteSpecialAdmin(PolymorphicChildModelAdmin):
"""
Child for a special note, see NoteAdmin
"""
readonly_fields = ('balance', )
readonly_fields = ('balance',)
@admin.register(NoteUser)
@ -79,7 +79,7 @@ class NoteUserAdmin(PolymorphicChildModelAdmin):
"""
Child for an user note, see NoteAdmin
"""
inlines = (AliasInlines, )
inlines = (AliasInlines,)
# We can't change user after creation or the balance
readonly_fields = ('user', 'balance')
@ -102,7 +102,7 @@ class TransactionAdmin(PolymorphicParentModelAdmin):
"""
Admin customisation for Transaction
"""
child_models = (TemplateTransaction, MembershipTransaction)
child_models = (RecurrentTransaction, MembershipTransaction)
list_display = ('created_at', 'poly_source', 'poly_destination',
'quantity', 'amount', 'valid')
list_filter = ('valid',)
@ -133,7 +133,7 @@ class TransactionAdmin(PolymorphicParentModelAdmin):
Else the amount of money would not be transferred
"""
if obj: # user is editing an existing object
return 'created_at', 'source', 'destination', 'quantity',\
return 'created_at', 'source', 'destination', 'quantity', \
'amount'
return []
@ -143,9 +143,9 @@ class TransactionTemplateAdmin(admin.ModelAdmin):
"""
Admin customisation for TransactionTemplate
"""
list_display = ('name', 'poly_destination', 'amount', 'category', 'display', )
list_display = ('name', 'poly_destination', 'amount', 'category', 'display',)
list_filter = ('category', 'display')
autocomplete_fields = ('destination', )
autocomplete_fields = ('destination',)
def poly_destination(self, obj):
"""
@ -161,5 +161,5 @@ class TemplateCategoryAdmin(admin.ModelAdmin):
"""
Admin customisation for TransactionTemplate
"""
list_display = ('name', )
list_filter = ('name', )
list_display = ('name',)
list_filter = ('name',)

View File

@ -5,7 +5,8 @@ from rest_framework import serializers
from rest_polymorphic.serializers import PolymorphicSerializer
from ..models.notes import Note, NoteClub, NoteSpecial, NoteUser, Alias
from ..models.transactions import TransactionTemplate, Transaction, MembershipTransaction
from ..models.transactions import TransactionTemplate, Transaction, MembershipTransaction, TemplateCategory, \
RecurrentTransaction, SpecialTransaction
class NoteSerializer(serializers.ModelSerializer):
@ -13,15 +14,11 @@ class NoteSerializer(serializers.ModelSerializer):
REST API Serializer for Notes.
The djangorestframework plugin will analyse the model `Note` and parse all fields in the API.
"""
class Meta:
model = Note
fields = '__all__'
extra_kwargs = {
'url': {
'view_name': 'project-detail',
'lookup_field': 'pk'
},
}
read_only_fields = [f.name for f in model._meta.get_fields()] # Notes are read-only protected
class NoteClubSerializer(serializers.ModelSerializer):
@ -29,9 +26,15 @@ class NoteClubSerializer(serializers.ModelSerializer):
REST API Serializer for Club's notes.
The djangorestframework plugin will analyse the model `NoteClub` and parse all fields in the API.
"""
name = serializers.SerializerMethodField()
class Meta:
model = NoteClub
fields = '__all__'
read_only_fields = ('note', 'club', )
def get_name(self, obj):
return str(obj)
class NoteSpecialSerializer(serializers.ModelSerializer):
@ -39,9 +42,15 @@ class NoteSpecialSerializer(serializers.ModelSerializer):
REST API Serializer for special notes.
The djangorestframework plugin will analyse the model `NoteSpecial` and parse all fields in the API.
"""
name = serializers.SerializerMethodField()
class Meta:
model = NoteSpecial
fields = '__all__'
read_only_fields = ('note', )
def get_name(self, obj):
return str(obj)
class NoteUserSerializer(serializers.ModelSerializer):
@ -49,9 +58,15 @@ class NoteUserSerializer(serializers.ModelSerializer):
REST API Serializer for User's notes.
The djangorestframework plugin will analyse the model `NoteUser` and parse all fields in the API.
"""
name = serializers.SerializerMethodField()
class Meta:
model = NoteUser
fields = '__all__'
read_only_fields = ('note', 'user', )
def get_name(self, obj):
return str(obj)
class AliasSerializer(serializers.ModelSerializer):
@ -59,9 +74,11 @@ class AliasSerializer(serializers.ModelSerializer):
REST API Serializer for Aliases.
The djangorestframework plugin will analyse the model `Alias` and parse all fields in the API.
"""
class Meta:
model = Alias
fields = '__all__'
read_only_fields = ('note', )
class NotePolymorphicSerializer(PolymorphicSerializer):
@ -72,12 +89,27 @@ class NotePolymorphicSerializer(PolymorphicSerializer):
NoteSpecial: NoteSpecialSerializer
}
class Meta:
model = Note
class TemplateCategorySerializer(serializers.ModelSerializer):
"""
REST API Serializer for Transaction templates.
The djangorestframework plugin will analyse the model `TemplateCategory` and parse all fields in the API.
"""
class Meta:
model = TemplateCategory
fields = '__all__'
class TransactionTemplateSerializer(serializers.ModelSerializer):
"""
REST API Serializer for Transaction templates.
The djangorestframework plugin will analyse the model `TransactionTemplate` and parse all fields in the API.
"""
class Meta:
model = TransactionTemplate
fields = '__all__'
@ -88,16 +120,52 @@ class TransactionSerializer(serializers.ModelSerializer):
REST API Serializer for Transactions.
The djangorestframework plugin will analyse the model `Transaction` and parse all fields in the API.
"""
class Meta:
model = Transaction
fields = '__all__'
class RecurrentTransactionSerializer(serializers.ModelSerializer):
"""
REST API Serializer for Transactions.
The djangorestframework plugin will analyse the model `RecurrentTransaction` and parse all fields in the API.
"""
class Meta:
model = RecurrentTransaction
fields = '__all__'
class MembershipTransactionSerializer(serializers.ModelSerializer):
"""
REST API Serializer for Membership transactions.
The djangorestframework plugin will analyse the model `MembershipTransaction` and parse all fields in the API.
"""
class Meta:
model = MembershipTransaction
fields = '__all__'
class SpecialTransactionSerializer(serializers.ModelSerializer):
"""
REST API Serializer for Special transactions.
The djangorestframework plugin will analyse the model `SpecialTransaction` and parse all fields in the API.
"""
class Meta:
model = SpecialTransaction
fields = '__all__'
class TransactionPolymorphicSerializer(PolymorphicSerializer):
model_serializer_mapping = {
Transaction: TransactionSerializer,
RecurrentTransaction: RecurrentTransactionSerializer,
MembershipTransaction: MembershipTransactionSerializer,
SpecialTransaction: SpecialTransactionSerializer,
}
class Meta:
model = Transaction

View File

@ -2,7 +2,7 @@
# SPDX-License-Identifier: GPL-3.0-or-later
from .views import NotePolymorphicViewSet, AliasViewSet, \
TransactionViewSet, TransactionTemplateViewSet, MembershipTransactionViewSet
TemplateCategoryViewSet, TransactionViewSet, TransactionTemplateViewSet
def register_note_urls(router, path):
@ -12,6 +12,6 @@ def register_note_urls(router, path):
router.register(path + '/note', NotePolymorphicViewSet)
router.register(path + '/alias', AliasViewSet)
router.register(path + '/transaction/category', TemplateCategoryViewSet)
router.register(path + '/transaction/transaction', TransactionViewSet)
router.register(path + '/transaction/template', TransactionTemplateViewSet)
router.register(path + '/transaction/membership', MembershipTransactionViewSet)

View File

@ -2,56 +2,18 @@
# SPDX-License-Identifier: GPL-3.0-or-later
from django.db.models import Q
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework.filters import OrderingFilter, SearchFilter
from api.viewsets import ReadProtectedModelViewSet, ReadOnlyProtectedModelViewSet
from rest_framework import viewsets
from ..models.notes import Note, NoteClub, NoteSpecial, NoteUser, Alias
from ..models.transactions import TransactionTemplate, Transaction, MembershipTransaction
from .serializers import NoteSerializer, NotePolymorphicSerializer, NoteClubSerializer, NoteSpecialSerializer, \
NoteUserSerializer, AliasSerializer, \
TransactionTemplateSerializer, TransactionSerializer, MembershipTransactionSerializer
from .serializers import NotePolymorphicSerializer, AliasSerializer, TemplateCategorySerializer, \
TransactionTemplateSerializer, TransactionPolymorphicSerializer
from ..models.notes import Note, Alias
from ..models.transactions import TransactionTemplate, Transaction, TemplateCategory
class NoteViewSet(viewsets.ModelViewSet):
"""
REST API View set.
The djangorestframework plugin will get all `Note` objects, serialize it to JSON with the given serializer,
then render it on /api/note/note/
"""
queryset = Note.objects.all()
serializer_class = NoteSerializer
class NoteClubViewSet(viewsets.ModelViewSet):
"""
REST API View set.
The djangorestframework plugin will get all `NoteClub` objects, serialize it to JSON with the given serializer,
then render it on /api/note/club/
"""
queryset = NoteClub.objects.all()
serializer_class = NoteClubSerializer
class NoteSpecialViewSet(viewsets.ModelViewSet):
"""
REST API View set.
The djangorestframework plugin will get all `NoteSpecial` objects, serialize it to JSON with the given serializer,
then render it on /api/note/special/
"""
queryset = NoteSpecial.objects.all()
serializer_class = NoteSpecialSerializer
class NoteUserViewSet(viewsets.ModelViewSet):
"""
REST API View set.
The djangorestframework plugin will get all `NoteUser` objects, serialize it to JSON with the given serializer,
then render it on /api/note/user/
"""
queryset = NoteUser.objects.all()
serializer_class = NoteUserSerializer
class NotePolymorphicViewSet(viewsets.ModelViewSet):
class NotePolymorphicViewSet(ReadOnlyProtectedModelViewSet):
"""
REST API View set.
The djangorestframework plugin will get all `Note` objects (with polymorhism), serialize it to JSON with the given serializer,
@ -59,36 +21,27 @@ class NotePolymorphicViewSet(viewsets.ModelViewSet):
"""
queryset = Note.objects.all()
serializer_class = NotePolymorphicSerializer
filter_backends = [SearchFilter, OrderingFilter]
search_fields = ['$alias__normalized_name', '$alias__name', '$polymorphic_ctype__model', ]
ordering_fields = ['alias__name', 'alias__normalized_name']
def get_queryset(self):
"""
Parse query and apply filters.
:return: The filtered set of requested notes
"""
queryset = Note.objects.all()
queryset = super().get_queryset()
alias = self.request.query_params.get("alias", ".*")
queryset = queryset.filter(
Q(alias__name__regex=alias)
| Q(alias__normalized_name__regex=alias.lower()))
Q(alias__name__regex="^" + alias)
| Q(alias__normalized_name__regex="^" + Alias.normalize(alias))
| Q(alias__normalized_name__regex="^" + alias.lower()))
note_type = self.request.query_params.get("type", None)
if note_type:
types = str(note_type).lower()
if "user" in types:
queryset = queryset.filter(polymorphic_ctype__model="noteuser")
elif "club" in types:
queryset = queryset.filter(polymorphic_ctype__model="noteclub")
elif "special" in types:
queryset = queryset.filter(
polymorphic_ctype__model="notespecial")
else:
queryset = queryset.none()
return queryset
return queryset.distinct()
class AliasViewSet(viewsets.ModelViewSet):
class AliasViewSet(ReadProtectedModelViewSet):
"""
REST API View set.
The djangorestframework plugin will get all `Alias` objects, serialize it to JSON with the given serializer,
@ -96,6 +49,9 @@ class AliasViewSet(viewsets.ModelViewSet):
"""
queryset = Alias.objects.all()
serializer_class = AliasSerializer
filter_backends = [SearchFilter, OrderingFilter]
search_fields = ['$normalized_name', '$name', '$note__polymorphic_ctype__model', ]
ordering_fields = ['name', 'normalized_name']
def get_queryset(self):
"""
@ -103,34 +59,29 @@ class AliasViewSet(viewsets.ModelViewSet):
:return: The filtered set of requested aliases
"""
queryset = Alias.objects.all()
queryset = super().get_queryset()
alias = self.request.query_params.get("alias", ".*")
queryset = queryset.filter(
Q(name__regex=alias) | Q(normalized_name__regex=alias.lower()))
note_id = self.request.query_params.get("note", None)
if note_id:
queryset = queryset.filter(id=note_id)
note_type = self.request.query_params.get("type", None)
if note_type:
types = str(note_type).lower()
if "user" in types:
queryset = queryset.filter(
note__polymorphic_ctype__model="noteuser")
elif "club" in types:
queryset = queryset.filter(
note__polymorphic_ctype__model="noteclub")
elif "special" in types:
queryset = queryset.filter(
note__polymorphic_ctype__model="notespecial")
else:
queryset = queryset.none()
Q(name__regex="^" + alias)
| Q(normalized_name__regex="^" + Alias.normalize(alias))
| Q(normalized_name__regex="^" + alias.lower()))
return queryset
class TemplateCategoryViewSet(ReadProtectedModelViewSet):
"""
REST API View set.
The djangorestframework plugin will get all `TemplateCategory` objects, serialize it to JSON with the given serializer,
then render it on /api/note/transaction/category/
"""
queryset = TemplateCategory.objects.all()
serializer_class = TemplateCategorySerializer
filter_backends = [SearchFilter]
search_fields = ['$name', ]
class TransactionTemplateViewSet(viewsets.ModelViewSet):
"""
REST API View set.
@ -139,23 +90,18 @@ class TransactionTemplateViewSet(viewsets.ModelViewSet):
"""
queryset = TransactionTemplate.objects.all()
serializer_class = TransactionTemplateSerializer
filter_backends = [SearchFilter, DjangoFilterBackend]
filterset_fields = ['name', 'amount', 'display', 'category', ]
search_fields = ['$name', ]
class TransactionViewSet(viewsets.ModelViewSet):
class TransactionViewSet(ReadProtectedModelViewSet):
"""
REST API View set.
The djangorestframework plugin will get all `Transaction` objects, serialize it to JSON with the given serializer,
then render it on /api/note/transaction/transaction/
"""
queryset = Transaction.objects.all()
serializer_class = TransactionSerializer
class MembershipTransactionViewSet(viewsets.ModelViewSet):
"""
REST API View set.
The djangorestframework plugin will get all `MembershipTransaction` objects, serialize it to JSON with the given serializer,
then render it on /api/note/transaction/membership/
"""
queryset = MembershipTransaction.objects.all()
serializer_class = MembershipTransactionSerializer
serializer_class = TransactionPolymorphicSerializer
filter_backends = [SearchFilter]
search_fields = ['$reason', ]

View File

@ -3,8 +3,12 @@
"model": "note.note",
"pk": 1,
"fields": {
"polymorphic_ctype": 22,
"polymorphic_ctype": [
"note",
"notespecial"
],
"balance": 0,
"last_negative": null,
"is_active": true,
"display_image": "",
"created_at": "2020-02-20T20:02:48.778Z"
@ -14,8 +18,12 @@
"model": "note.note",
"pk": 2,
"fields": {
"polymorphic_ctype": 22,
"polymorphic_ctype": [
"note",
"notespecial"
],
"balance": 0,
"last_negative": null,
"is_active": true,
"display_image": "",
"created_at": "2020-02-20T20:06:39.546Z"
@ -25,8 +33,12 @@
"model": "note.note",
"pk": 3,
"fields": {
"polymorphic_ctype": 22,
"polymorphic_ctype": [
"note",
"notespecial"
],
"balance": 0,
"last_negative": null,
"is_active": true,
"display_image": "",
"created_at": "2020-02-20T20:06:43.049Z"
@ -36,8 +48,12 @@
"model": "note.note",
"pk": 4,
"fields": {
"polymorphic_ctype": 22,
"polymorphic_ctype": [
"note",
"notespecial"
],
"balance": 0,
"last_negative": null,
"is_active": true,
"display_image": "",
"created_at": "2020-02-20T20:06:50.996Z"
@ -47,10 +63,14 @@
"model": "note.note",
"pk": 5,
"fields": {
"polymorphic_ctype": 21,
"polymorphic_ctype": [
"note",
"noteclub"
],
"balance": 0,
"last_negative": null,
"is_active": true,
"display_image": "",
"display_image": "pic/default.png",
"created_at": "2020-02-20T20:09:38.615Z"
}
},
@ -58,13 +78,31 @@
"model": "note.note",
"pk": 6,
"fields": {
"polymorphic_ctype": 21,
"polymorphic_ctype": [
"note",
"noteclub"
],
"balance": 0,
"last_negative": null,
"is_active": true,
"display_image": "",
"display_image": "pic/default.png",
"created_at": "2020-02-20T20:16:14.753Z"
}
},
{
"model": "note.noteclub",
"pk": 5,
"fields": {
"club": 1
}
},
{
"model": "note.noteclub",
"pk": 6,
"fields": {
"club": 2
}
},
{
"model": "note.notespecial",
"pk": 1,
@ -93,20 +131,6 @@
"special_type": "Virement bancaire"
}
},
{
"model": "note.noteclub",
"pk": 5,
"fields": {
"club": 1
}
},
{
"model": "note.noteclub",
"pk": 6,
"fields": {
"club": 2
}
},
{
"model": "note.alias",
"pk": 1,

View File

@ -5,7 +5,29 @@ from dal import autocomplete
from django import forms
from django.utils.translation import gettext_lazy as _
from .models import Transaction, TransactionTemplate, TemplateTransaction
from .models import Alias
from .models import TransactionTemplate
class AliasForm(forms.ModelForm):
class Meta:
model = Alias
fields = ("name",)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["name"].label = False
self.fields["name"].widget.attrs = {"placeholder": _('New Alias')}
class ImageForm(forms.Form):
image = forms.ImageField(required=False,
label=_('select an image'),
help_text=_('Maximal size: 2MB'))
x = forms.FloatField(widget=forms.HiddenInput())
y = forms.FloatField(widget=forms.HiddenInput())
width = forms.FloatField(widget=forms.HiddenInput())
height = forms.FloatField(widget=forms.HiddenInput())
class TransactionTemplateForm(forms.ModelForm):
@ -28,84 +50,3 @@ class TransactionTemplateForm(forms.ModelForm):
},
),
}
class TransactionForm(forms.ModelForm):
def save(self, commit=True):
super().save(commit)
def clean(self):
"""
If the user has no right to transfer funds, then it will be the source of the transfer by default.
Transactions between a note and the same note are not authorized.
"""
cleaned_data = super().clean()
if not "source" in cleaned_data: # TODO Replace it with "if %user has no right to transfer funds"
cleaned_data["source"] = self.user.note
if cleaned_data["source"].pk == cleaned_data["destination"].pk:
self.add_error("destination", _("Source and destination must be different."))
return cleaned_data
class Meta:
model = Transaction
fields = (
'source',
'destination',
'reason',
'amount',
)
# Voir ci-dessus
widgets = {
'source':
autocomplete.ModelSelect2(
url='note:note_autocomplete',
attrs={
'data-placeholder': 'Note ...',
'data-minimum-input-length': 1,
},
),
'destination':
autocomplete.ModelSelect2(
url='note:note_autocomplete',
attrs={
'data-placeholder': 'Note ...',
'data-minimum-input-length': 1,
},
),
}
class ConsoForm(forms.ModelForm):
def save(self, commit=True):
button: TransactionTemplate = TransactionTemplate.objects.filter(
name=self.data['button']).get()
self.instance.destination = button.destination
self.instance.amount = button.amount
self.instance.reason = '{} ({})'.format(button.name, button.category)
self.instance.name = button.name
self.instance.category = button.category
super().save(commit)
class Meta:
model = TemplateTransaction
fields = ('source', )
# Le champ d'utilisateur est remplacé par un champ d'auto-complétion.
# Quand des lettres sont tapées, une requête est envoyée sur l'API d'auto-complétion
# et récupère les aliases de note valides
widgets = {
'source':
autocomplete.ModelSelect2(
url='note:note_autocomplete',
attrs={
'data-placeholder': 'Note ...',
'data-minimum-input-length': 1,
},
),
}

View File

@ -3,12 +3,12 @@
from .notes import Alias, Note, NoteClub, NoteSpecial, NoteUser
from .transactions import MembershipTransaction, Transaction, \
TemplateCategory, TransactionTemplate, TemplateTransaction
TemplateCategory, TransactionTemplate, RecurrentTransaction, SpecialTransaction
__all__ = [
# Notes
'Alias', 'Note', 'NoteClub', 'NoteSpecial', 'NoteUser',
# Transactions
'MembershipTransaction', 'Transaction', 'TemplateCategory', 'TransactionTemplate',
'TemplateTransaction',
'RecurrentTransaction', 'SpecialTransaction',
]

View File

@ -9,6 +9,7 @@ from django.core.validators import RegexValidator
from django.db import models
from django.utils.translation import gettext_lazy as _
from polymorphic.models import PolymorphicModel
"""
Defines each note types
"""
@ -27,7 +28,7 @@ class Note(PolymorphicModel):
help_text=_('in centimes, money credited for this instance'),
default=0,
)
last_negative= models.DateTimeField(
last_negative = models.DateTimeField(
verbose_name=_('last negative date'),
help_text=_('last time the balance was negative'),
null=True,
@ -43,7 +44,10 @@ class Note(PolymorphicModel):
display_image = models.ImageField(
verbose_name=_('display image'),
max_length=255,
blank=True,
blank=False,
null=False,
upload_to='pic/',
default='pic/default.png'
)
created_at = models.DateTimeField(
verbose_name=_('created at'),
@ -95,7 +99,7 @@ class Note(PolymorphicModel):
# Alias exists, so check if it is linked to this note
if aliases.first().note != self:
raise ValidationError(_('This alias is already taken.'),
code="same_alias",)
code="same_alias", )
else:
# Alias does not exist yet, so check if it can exist
a = Alias(name=str(self))
@ -205,6 +209,10 @@ class Alias(models.Model):
class Meta:
verbose_name = _("alias")
verbose_name_plural = _("aliases")
indexes = [
models.Index(fields=['name']),
models.Index(fields=['normalized_name']),
]
def __str__(self):
return self.name
@ -219,14 +227,6 @@ class Alias(models.Model):
if all(not unicodedata.category(char).startswith(cat)
for cat in {'M', 'P', 'Z', 'C'})).casefold()
def save(self, *args, **kwargs):
"""
Handle normalized_name
"""
self.normalized_name = Alias.normalize(self.name)
if len(self.normalized_name) < 256:
super().save(*args, **kwargs)
def clean(self):
normalized_name = Alias.normalize(self.name)
if len(normalized_name) >= 255:
@ -235,11 +235,12 @@ class Alias(models.Model):
try:
sim_alias = Alias.objects.get(normalized_name=normalized_name)
if self != sim_alias:
raise ValidationError(_('An alias with a similar name already exists:'),
raise ValidationError(_('An alias with a similar name already exists: {} ').format(sim_alias),
code="same_alias"
)
except Alias.DoesNotExist:
pass
self.normalized_name = normalized_name
def delete(self, using=None, keep_parents=False):
if self.name == str(self.note):

View File

@ -2,12 +2,12 @@
# SPDX-License-Identifier: GPL-3.0-or-later
from django.db import models
from django.urls import reverse
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from django.urls import reverse
from polymorphic.models import PolymorphicModel
from .notes import Note, NoteClub
from .notes import Note, NoteClub, NoteSpecial
"""
Defines transactions
@ -44,7 +44,7 @@ class TransactionTemplate(models.Model):
verbose_name=_('name'),
max_length=255,
unique=True,
error_messages={'unique':_("A template with this name already exist")},
error_messages={'unique': _("A template with this name already exist")},
)
destination = models.ForeignKey(
NoteClub,
@ -63,11 +63,12 @@ class TransactionTemplate(models.Model):
max_length=31,
)
display = models.BooleanField(
default = True,
default=True,
)
description = models.CharField(
verbose_name=_('description'),
max_length=255,
blank=True,
)
class Meta:
@ -75,7 +76,7 @@ class TransactionTemplate(models.Model):
verbose_name_plural = _("transaction templates")
def get_absolute_url(self):
return reverse('note:template_update', args=(self.pk, ))
return reverse('note:template_update', args=(self.pk,))
class Transaction(PolymorphicModel):
@ -106,7 +107,10 @@ class Transaction(PolymorphicModel):
verbose_name=_('quantity'),
default=1,
)
amount = models.PositiveIntegerField(verbose_name=_('amount'), )
amount = models.PositiveIntegerField(
verbose_name=_('amount'),
)
reason = models.CharField(
verbose_name=_('reason'),
max_length=255,
@ -119,6 +123,11 @@ class Transaction(PolymorphicModel):
class Meta:
verbose_name = _("transaction")
verbose_name_plural = _("transactions")
indexes = [
models.Index(fields=['created_at']),
models.Index(fields=['source']),
models.Index(fields=['destination']),
]
def save(self, *args, **kwargs):
"""
@ -127,6 +136,7 @@ class Transaction(PolymorphicModel):
if self.source.pk == self.destination.pk:
# When source == destination, no money is transfered
super().save(*args, **kwargs)
return
created = self.pk is None
@ -142,20 +152,25 @@ class Transaction(PolymorphicModel):
self.source.balance -= to_transfer
self.destination.balance += to_transfer
# We save first the transaction, in case of the user has no right to transfer money
super().save(*args, **kwargs)
# Save notes
self.source.save()
self.destination.save()
super().save(*args, **kwargs)
@property
def total(self):
return self.amount * self.quantity
@property
def type(self):
return _('Transfer')
class TemplateTransaction(Transaction):
class RecurrentTransaction(Transaction):
"""
Special type of :model:`note.Transaction` associated to a :model:`note.TransactionTemplate`.
"""
template = models.ForeignKey(
@ -168,6 +183,37 @@ class TemplateTransaction(Transaction):
on_delete=models.PROTECT,
)
@property
def type(self):
return _('Template')
class SpecialTransaction(Transaction):
"""
Special type of :model:`note.Transaction` associated to transactions with special notes
"""
last_name = models.CharField(
max_length=255,
verbose_name=_("name"),
)
first_name = models.CharField(
max_length=255,
verbose_name=_("first_name"),
)
bank = models.CharField(
max_length=255,
verbose_name=_("bank"),
blank=True,
)
@property
def type(self):
return _('Credit') if isinstance(self.source, NoteSpecial) else _("Debit")
class MembershipTransaction(Transaction):
"""
Special type of :model:`note.Transaction` associated to a :model:`member.Membership`.
@ -183,3 +229,7 @@ class MembershipTransaction(Transaction):
class Meta:
verbose_name = _("membership transaction")
verbose_name_plural = _("membership transactions")
@property
def type(self):
return _('membership transaction')

View File

@ -1,10 +1,16 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
import html
import django_tables2 as tables
from django.db.models import F
from django_tables2.utils import A
from django.utils.translation import gettext_lazy as _
from .models.transactions import Transaction
from .models.notes import Alias
from .models.transactions import Transaction, TransactionTemplate
from .templatetags.pretty_money import pretty_money
class HistoryTable(tables.Table):
@ -14,13 +20,96 @@ class HistoryTable(tables.Table):
'table table-condensed table-striped table-hover'
}
model = Transaction
exclude = ("id", "polymorphic_ctype", )
template_name = 'django_tables2/bootstrap4.html'
sequence = ('...', 'total', 'valid')
sequence = ('...', 'type', 'total', 'valid', )
orderable = False
type = tables.Column()
total = tables.Column() # will use Transaction.total() !!
valid = tables.Column(attrs={"td": {"id": lambda record: "validate_" + str(record.id),
"class": lambda record: str(record.valid).lower() + ' validate',
"onclick": lambda record: 'de_validate(' + str(record.id) + ', '
+ str(record.valid).lower() + ')'}})
def order_total(self, queryset, is_descending):
# needed for rendering
queryset = queryset.annotate(total=F('amount') * F('quantity')) \
.order_by(('-' if is_descending else '') + 'total')
return (queryset, True)
return queryset, True
def render_amount(self, value):
return pretty_money(value)
def render_total(self, value):
return pretty_money(value)
def render_type(self, value):
return _(value)
# Django-tables escape strings. That's a wrong thing.
def render_reason(self, value):
return html.unescape(value)
def render_valid(self, value):
return "" if value else ""
# function delete_button(id) provided in template file
DELETE_TEMPLATE = """
<button id="{{ record.pk }}" class="btn btn-danger" onclick="delete_button(this.id)"> {{ delete_trans }}</button>
"""
class AliasTable(tables.Table):
class Meta:
attrs = {
'class':
'table table condensed table-striped table-hover'
}
model = Alias
fields = ('name',)
template_name = 'django_tables2/bootstrap4.html'
show_header = False
name = tables.Column(attrs={'td': {'class': 'text-center'}})
# delete = tables.TemplateColumn(template_code=delete_template,
# attrs={'td':{'class': 'col-sm-1'}})
delete = tables.LinkColumn('member:user_alias_delete',
args=[A('pk')],
attrs={
'td': {'class': 'col-sm-2'},
'a': {'class': 'btn btn-danger'}},
text='delete', accessor='pk')
class ButtonTable(tables.Table):
class Meta:
attrs = {
'class':
'table table-bordered condensed table-hover'
}
row_attrs = {
'class': lambda record: 'table-row ' + 'table-success' if record.display else 'table-danger',
'id': lambda record: "row-" + str(record.pk),
'data-href': lambda record: record.pk
}
model = TransactionTemplate
edit = tables.LinkColumn('note:template_update',
args=[A('pk')],
attrs={'td': {'class': 'col-sm-1'},
'a': {'class': 'btn btn-primary'}},
text=_('edit'),
accessor='pk')
delete = tables.TemplateColumn(template_code=DELETE_TEMPLATE,
extra_context={"delete_trans": _('delete')},
attrs={'td': {'class': 'col-sm-1'}})
def render_amount(self, value):
return pretty_money(value)

View File

@ -0,0 +1,14 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from django import template
import os
def getenv(value):
return os.getenv(value)
register = template.Library()
register.filter('getenv', getenv)

View File

@ -11,12 +11,17 @@ def pretty_money(value):
abs(value) // 100,
)
else:
return "{:s}{:d}{:02d}".format(
return "{:s}{:d}.{:02d}".format(
"- " if value < 0 else "",
abs(value) // 100,
abs(value) % 100,
)
def cents_to_euros(value):
return "{:.02f}".format(value / 100) if value else ""
register = template.Library()
register.filter('pretty_money', pretty_money)
register.filter('cents_to_euros', cents_to_euros)

View File

@ -8,7 +8,7 @@ from .models import Note
app_name = 'note'
urlpatterns = [
path('transfer/', views.TransactionCreate.as_view(), name='transfer'),
path('transfer/', views.TransactionCreateView.as_view(), name='transfer'),
path('buttons/create/', views.TransactionTemplateCreateView.as_view(), name='template_create'),
path('buttons/update/<int:pk>/', views.TransactionTemplateUpdateView.as_view(), name='template_update'),
path('buttons/', views.TransactionTemplateListView.as_view(), name='template_list'),

View File

@ -3,60 +3,60 @@
from dal import autocomplete
from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib.contenttypes.models import ContentType
from django.db.models import Q
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from django.views.generic import CreateView, ListView, UpdateView
from django.views.generic import CreateView, UpdateView
from django_tables2 import SingleTableView
from django.urls import reverse_lazy
from permission.backends import PermissionBackend
from .models import Transaction, TransactionTemplate, Alias, TemplateTransaction
from .forms import TransactionForm, TransactionTemplateForm, ConsoForm
from .forms import TransactionTemplateForm
from .models import Transaction, TransactionTemplate, Alias, RecurrentTransaction, NoteSpecial
from .models.transactions import SpecialTransaction
from .tables import HistoryTable, ButtonTable
class TransactionCreate(LoginRequiredMixin, CreateView):
class TransactionCreateView(LoginRequiredMixin, SingleTableView):
"""
Show transfer page
TODO: If user have sufficient rights, they can transfer from an other note
View for the creation of Transaction between two note which are not :models:`transactions.RecurrentTransaction`.
e.g. for donation/transfer between people and clubs or for credit/debit with :models:`note.NoteSpecial`
"""
template_name = "note/transaction_form.html"
model = Transaction
form_class = TransactionForm
# Transaction history table
table_class = HistoryTable
table_pagination = {"per_page": 50}
def get_queryset(self):
return Transaction.objects.filter(PermissionBackend.filter_queryset(
self.request.user, Transaction, "view")
).order_by("-id").all()[:50]
def get_context_data(self, **kwargs):
"""
Add some context variables in template such as page title
"""
context = super().get_context_data(**kwargs)
context['title'] = _('Transfer money from your account '
'to one or others')
context['no_cache'] = True
context['title'] = _('Transfer money')
context['polymorphic_ctype'] = ContentType.objects.get_for_model(Transaction).pk
context['special_polymorphic_ctype'] = ContentType.objects.get_for_model(SpecialTransaction).pk
context['special_types'] = NoteSpecial.objects.order_by("special_type").all()
return context
def get_form(self, form_class=None):
"""
If the user has no right to transfer funds, then it won't have the choice of the source of the transfer.
"""
form = super().get_form(form_class)
if False: # TODO: fix it with "if %user has no right to transfer funds"
del form.fields['source']
form.user = self.request.user
return form
def get_success_url(self):
return reverse('note:transfer')
class NoteAutocomplete(autocomplete.Select2QuerySetView):
"""
Auto complete note by aliases
Auto complete note by aliases. Used in every search field for note
ex: :view:`ConsoView`, :view:`TransactionCreateView`
"""
def get_queryset(self):
"""
Quand une personne cherche un alias, une requête est envoyée sur l'API dédiée à l'auto-complétion.
Cette fonction récupère la requête, et renvoie la liste filtrée des aliases.
When someone look for an :models:`note.Alias`, a query is sent to the dedicated API.
This function handles the result and return a filtered list of aliases.
"""
# Un utilisateur non connecté n'a accès à aucune information
if not self.request.user.is_authenticated:
@ -66,7 +66,7 @@ class NoteAutocomplete(autocomplete.Select2QuerySetView):
# self.q est le paramètre de la recherche
if self.q:
qs = qs.filter(Q(name__regex=self.q) | Q(normalized_name__regex=Alias.normalize(self.q)))\
qs = qs.filter(Q(name__regex="^" + self.q) | Q(normalized_name__regex="^" + Alias.normalize(self.q))) \
.order_by('normalized_name').distinct()
# Filtrage par type de note (user, club, special)
@ -85,6 +85,10 @@ class NoteAutocomplete(autocomplete.Select2QuerySetView):
return qs
def get_result_label(self, result):
"""
Show the selected alias and the username associated
<Alias> (aka. <Username> )
"""
# Gère l'affichage de l'alias dans la recherche
res = result.name
note_name = str(result.note)
@ -93,7 +97,9 @@ class NoteAutocomplete(autocomplete.Select2QuerySetView):
return res
def get_result_value(self, result):
# Le résultat renvoyé doit être l'identifiant de la note, et non de l'alias
"""
The value used for the transactions will be the id of the Note.
"""
return str(result.note.pk)
@ -103,14 +109,15 @@ class TransactionTemplateCreateView(LoginRequiredMixin, CreateView):
"""
model = TransactionTemplate
form_class = TransactionTemplateForm
success_url = reverse_lazy('note:template_list')
class TransactionTemplateListView(LoginRequiredMixin, ListView):
class TransactionTemplateListView(LoginRequiredMixin, SingleTableView):
"""
List TransactionsTemplates
"""
model = TransactionTemplate
form_class = TransactionTemplateForm
table_class = ButtonTable
class TransactionTemplateUpdateView(LoginRequiredMixin, UpdateView):
@ -118,33 +125,40 @@ class TransactionTemplateUpdateView(LoginRequiredMixin, UpdateView):
"""
model = TransactionTemplate
form_class = TransactionTemplateForm
success_url = reverse_lazy('note:template_list')
class ConsoView(LoginRequiredMixin, CreateView):
class ConsoView(LoginRequiredMixin, SingleTableView):
"""
Consume
The Magic View that make people pay their beer and burgers.
(Most of the magic happens in the dark world of Javascript see consos.js)
"""
model = TemplateTransaction
template_name = "note/conso_form.html"
form_class = ConsoForm
# Transaction history table
table_class = HistoryTable
table_pagination = {"per_page": 50}
def get_queryset(self):
return Transaction.objects.filter(
PermissionBackend.filter_queryset(self.request.user, Transaction, "view")
).order_by("-id").all()[:50]
def get_context_data(self, **kwargs):
"""
Add some context variables in template such as page title
"""
context = super().get_context_data(**kwargs)
context['transaction_templates'] = TransactionTemplate.objects.filter(display=True) \
.order_by('category')
context['title'] = _("Consommations")
from django.db.models import Count
buttons = TransactionTemplate.objects.filter(
PermissionBackend().filter_queryset(self.request.user, TransactionTemplate, "view")
).filter(display=True).annotate(clicks=Count('recurrenttransaction')).order_by('category__name', 'name')
context['transaction_templates'] = buttons
context['most_used'] = buttons.order_by('-clicks', 'name')[:10]
context['title'] = _("Consumptions")
context['polymorphic_ctype'] = ContentType.objects.get_for_model(RecurrentTransaction).pk
# select2 compatibility
context['no_cache'] = True
return context
def get_success_url(self):
"""
When clicking a button, reload the same page
"""
return reverse('note:consos')

View File

@ -0,0 +1,4 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
default_app_config = 'permission.apps.PermissionConfig'

30
apps/permission/admin.py Normal file
View File

@ -0,0 +1,30 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-lateré
from django.contrib import admin
from .models import Permission, PermissionMask, RolePermissions
@admin.register(PermissionMask)
class PermissionMaskAdmin(admin.ModelAdmin):
"""
Admin customisation for PermissionMask
"""
list_display = ('description', 'rank', )
@admin.register(Permission)
class PermissionAdmin(admin.ModelAdmin):
"""
Admin customisation for Permission
"""
list_display = ('type', 'model', 'field', 'mask', 'description', )
@admin.register(RolePermissions)
class RolePermissionsAdmin(admin.ModelAdmin):
"""
Admin customisation for RolePermissions
"""
list_display = ('role', )

View File

View File

@ -0,0 +1,17 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from rest_framework import serializers
from ..models import Permission
class PermissionSerializer(serializers.ModelSerializer):
"""
REST API Serializer for Permission types.
The djangorestframework plugin will analyse the model `Permission` and parse all fields in the API.
"""
class Meta:
model = Permission
fields = '__all__'

View File

@ -0,0 +1,11 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from .views import PermissionViewSet
def register_permission_urls(router, path):
"""
Configure router for permission REST API.
"""
router.register(path, PermissionViewSet)

View File

@ -0,0 +1,20 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from django_filters.rest_framework import DjangoFilterBackend
from api.viewsets import ReadOnlyProtectedModelViewSet
from .serializers import PermissionSerializer
from ..models import Permission
class PermissionViewSet(ReadOnlyProtectedModelViewSet):
"""
REST API View set.
The djangorestframework plugin will get all `Changelog` objects, serialize it to JSON with the given serializer,
then render it on /api/logs/
"""
queryset = Permission.objects.all()
serializer_class = PermissionSerializer
filter_backends = [DjangoFilterBackend]
filterset_fields = ['model', 'type', ]

14
apps/permission/apps.py Normal file
View File

@ -0,0 +1,14 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from django.apps import AppConfig
from django.db.models.signals import pre_save, pre_delete
class PermissionConfig(AppConfig):
name = 'permission'
def ready(self):
from . import signals
pre_save.connect(signals.pre_save_object)
pre_delete.connect(signals.pre_delete_object)

116
apps/permission/backends.py Normal file
View File

@ -0,0 +1,116 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from django.contrib.auth.backends import ModelBackend
from django.contrib.auth.models import User, AnonymousUser
from django.contrib.contenttypes.models import ContentType
from django.db.models import Q, F
from note.models import Note, NoteUser, NoteClub, NoteSpecial
from note_kfet.middlewares import get_current_session
from member.models import Membership, Club
from .models import Permission
class PermissionBackend(ModelBackend):
"""
Manage permissions of users
"""
supports_object_permissions = True
supports_anonymous_user = False
supports_inactive_user = False
@staticmethod
def permissions(user, model, type):
"""
List all permissions of the given user that applies to a given model and a give type
:param user: The owner of the permissions
:param model: The model that the permissions shoud apply
:param type: The type of the permissions: view, change, add or delete
:return: A generator of the requested permissions
"""
for permission in Permission.objects.annotate(club=F("rolepermissions__role__membership__club")) \
.filter(
rolepermissions__role__membership__user=user,
model__app_label=model.app_label, # For polymorphic models, we don't filter on model type
type=type,
).all():
if not isinstance(model, permission.model.__class__):
continue
club = Club.objects.get(pk=permission.club)
permission = permission.about(
user=user,
club=club,
User=User,
Club=Club,
Membership=Membership,
Note=Note,
NoteUser=NoteUser,
NoteClub=NoteClub,
NoteSpecial=NoteSpecial,
F=F,
Q=Q
)
if permission.mask.rank <= get_current_session().get("permission_mask", 0):
yield permission
@staticmethod
def filter_queryset(user, model, t, field=None):
"""
Filter a queryset by considering the permissions of a given user.
:param user: The owner of the permissions that are fetched
:param model: The concerned model of the queryset
:param t: The type of modification (view, add, change, delete)
:param field: The field of the model to test, if concerned
:return: A query that corresponds to the filter to give to a queryset
"""
if user is None or isinstance(user, AnonymousUser):
# Anonymous users can't do anything
return Q(pk=-1)
if user.is_superuser and get_current_session().get("permission_mask", 0) >= 42:
# Superusers have all rights
return Q()
if not isinstance(model, ContentType):
model = ContentType.objects.get_for_model(model)
# Never satisfied
query = Q(pk=-1)
perms = PermissionBackend.permissions(user, model, t)
for perm in perms:
if perm.field and field != perm.field:
continue
if perm.type != t or perm.model != model:
continue
perm.update_query()
query = query | perm.query
return query
def has_perm(self, user_obj, perm, obj=None):
if user_obj is None or isinstance(user_obj, AnonymousUser):
return False
if user_obj.is_superuser and get_current_session().get("permission_mask", 0) >= 42:
return True
if obj is None:
return True
perm = perm.split('.')[-1].split('_', 2)
perm_type = perm[0]
perm_field = perm[2] if len(perm) == 3 else None
ct = ContentType.objects.get_for_model(obj)
if any(permission.applies(obj, perm_type, perm_field)
for permission in self.permissions(user_obj, ct, perm_type)):
return True
return False
def has_module_perms(self, user_obj, app_label):
return False
def get_all_permissions(self, user_obj, obj=None):
ct = ContentType.objects.get_for_model(obj)
return list(self.permissions(user_obj, ct, "view"))

View File

@ -0,0 +1,653 @@
[
{
"model": "member.role",
"pk": 1,
"fields": {
"name": "Adh\u00e9rent BDE"
}
},
{
"model": "member.role",
"pk": 2,
"fields": {
"name": "Adh\u00e9rent Kfet"
}
},
{
"model": "member.role",
"pk": 3,
"fields": {
"name": "Pr\u00e9sident\u00b7e BDE"
}
},
{
"model": "member.role",
"pk": 4,
"fields": {
"name": "Tr\u00e9sorier\u00b7\u00e8re BDE"
}
},
{
"model": "member.role",
"pk": 5,
"fields": {
"name": "Respo info"
}
},
{
"model": "member.role",
"pk": 6,
"fields": {
"name": "GC Kfet"
}
},
{
"model": "member.role",
"pk": 7,
"fields": {
"name": "Pr\u00e9sident\u00b7e de club"
}
},
{
"model": "member.role",
"pk": 8,
"fields": {
"name": "Tr\u00e9sorier\u00b7\u00e8re de club"
}
},
{
"model": "permission.permissionmask",
"pk": 1,
"fields": {
"rank": 0,
"description": "Droits basiques"
}
},
{
"model": "permission.permissionmask",
"pk": 2,
"fields": {
"rank": 1,
"description": "Droits note seulement"
}
},
{
"model": "permission.permissionmask",
"pk": 3,
"fields": {
"rank": 42,
"description": "Tous mes droits"
}
},
{
"model": "permission.permission",
"pk": 1,
"fields": {
"model": [
"auth",
"user"
],
"query": "{\"pk\": [\"user\", \"pk\"]}",
"type": "view",
"mask": 1,
"field": "",
"description": "View our User object"
}
},
{
"model": "permission.permission",
"pk": 2,
"fields": {
"model": [
"member",
"profile"
],
"query": "{\"user\": [\"user\"]}",
"type": "view",
"mask": 1,
"field": "",
"description": "View our profile"
}
},
{
"model": "permission.permission",
"pk": 3,
"fields": {
"model": [
"note",
"noteuser"
],
"query": "{\"pk\": [\"user\", \"note\", \"pk\"]}",
"type": "view",
"mask": 1,
"field": "",
"description": "View our own note"
}
},
{
"model": "permission.permission",
"pk": 4,
"fields": {
"model": [
"authtoken",
"token"
],
"query": "{\"user\": [\"user\"]}",
"type": "view",
"mask": 1,
"field": "",
"description": "View our API token"
}
},
{
"model": "permission.permission",
"pk": 5,
"fields": {
"model": [
"note",
"transaction"
],
"query": "[\"OR\", {\"source\": [\"user\", \"note\"]}, {\"destination\": [\"user\", \"note\"]}]",
"type": "view",
"mask": 1,
"field": "",
"description": "View our own transactions"
}
},
{
"model": "permission.permission",
"pk": 6,
"fields": {
"model": [
"note",
"alias"
],
"query": "[\"OR\", {\"note__in\": [\"NoteUser\", \"objects\", [\"filter\", {\"user__membership__club__name\": \"Kfet\"}], [\"all\"]]}, {\"note__in\": [\"NoteClub\", \"objects\", [\"all\"]]}]",
"type": "view",
"mask": 1,
"field": "",
"description": "View aliases of clubs and members of Kfet club"
}
},
{
"model": "permission.permission",
"pk": 7,
"fields": {
"model": [
"auth",
"user"
],
"query": "{\"pk\": [\"user\", \"pk\"]}",
"type": "change",
"mask": 1,
"field": "last_login",
"description": "Change myself's last login"
}
},
{
"model": "permission.permission",
"pk": 8,
"fields": {
"model": [
"auth",
"user"
],
"query": "{\"pk\": [\"user\", \"pk\"]}",
"type": "change",
"mask": 1,
"field": "username",
"description": "Change myself's username"
}
},
{
"model": "permission.permission",
"pk": 9,
"fields": {
"model": [
"auth",
"user"
],
"query": "{\"pk\": [\"user\", \"pk\"]}",
"type": "change",
"mask": 1,
"field": "first_name",
"description": "Change myself's first name"
}
},
{
"model": "permission.permission",
"pk": 10,
"fields": {
"model": [
"auth",
"user"
],
"query": "{\"pk\": [\"user\", \"pk\"]}",
"type": "change",
"mask": 1,
"field": "last_name",
"description": "Change myself's last name"
}
},
{
"model": "permission.permission",
"pk": 11,
"fields": {
"model": [
"auth",
"user"
],
"query": "{\"pk\": [\"user\", \"pk\"]}",
"type": "change",
"mask": 1,
"field": "email",
"description": "Change myself's email"
}
},
{
"model": "permission.permission",
"pk": 12,
"fields": {
"model": [
"authtoken",
"token"
],
"query": "{\"user\": [\"user\"]}",
"type": "delete",
"mask": 1,
"field": "",
"description": "Delete API Token"
}
},
{
"model": "permission.permission",
"pk": 13,
"fields": {
"model": [
"authtoken",
"token"
],
"query": "{\"user\": [\"user\"]}",
"type": "add",
"mask": 1,
"field": "",
"description": "Create API Token"
}
},
{
"model": "permission.permission",
"pk": 14,
"fields": {
"model": [
"note",
"alias"
],
"query": "{\"note\": [\"user\", \"note\"]}",
"type": "delete",
"mask": 1,
"field": "",
"description": "Remove alias"
}
},
{
"model": "permission.permission",
"pk": 15,
"fields": {
"model": [
"note",
"alias"
],
"query": "{\"note\": [\"user\", \"note\"]}",
"type": "add",
"mask": 1,
"field": "",
"description": "Add alias"
}
},
{
"model": "permission.permission",
"pk": 16,
"fields": {
"model": [
"note",
"noteuser"
],
"query": "{\"pk\": [\"user\", \"note\", \"pk\"]}",
"type": "change",
"mask": 1,
"field": "display_image",
"description": "Change myself's display image"
}
},
{
"model": "permission.permission",
"pk": 17,
"fields": {
"model": [
"note",
"transaction"
],
"query": "[\"AND\", {\"source\": [\"user\", \"note\"]}, {\"amount__lte\": [\"user\", \"note\", \"balance\"]}]",
"type": "add",
"mask": 1,
"field": "",
"description": "Transfer from myself's note"
}
},
{
"model": "permission.permission",
"pk": 18,
"fields": {
"model": [
"note",
"note"
],
"query": "{}",
"type": "change",
"mask": 1,
"field": "balance",
"description": "Update a note balance with a transaction"
}
},
{
"model": "permission.permission",
"pk": 19,
"fields": {
"model": [
"note",
"note"
],
"query": "[\"OR\", {\"pk\": [\"club\", \"note\", \"pk\"]}, {\"pk__in\": [\"NoteUser\", \"objects\", [\"filter\", {\"user__membership__club\": [\"club\"]}], [\"all\"]]}]",
"type": "view",
"mask": 2,
"field": "",
"description": "View notes of club members"
}
},
{
"model": "permission.permission",
"pk": 20,
"fields": {
"model": [
"note",
"transaction"
],
"query": "[\"AND\", [\"OR\", {\"source\": [\"club\", \"note\"]}, {\"destination\": [\"club\", \"note\"]}], {\"amount__lte\": {\"F\": [\"ADD\", [\"F\", \"source__balance\"], 5000]}}]",
"type": "add",
"mask": 2,
"field": "",
"description": "Create transactions with a club"
}
},
{
"model": "permission.permission",
"pk": 21,
"fields": {
"model": [
"note",
"recurrenttransaction"
],
"query": "[\"AND\", {\"destination\": [\"club\", \"note\"]}, {\"amount__lte\": {\"F\": [\"ADD\", [\"F\", \"source__balance\"], 5000]}}]",
"type": "add",
"mask": 2,
"field": "",
"description": "Create transactions from buttons with a club"
}
},
{
"model": "permission.permission",
"pk": 22,
"fields": {
"model": [
"member",
"club"
],
"query": "{\"pk\": [\"club\", \"pk\"]}",
"type": "view",
"mask": 1,
"field": "",
"description": "View club infos"
}
},
{
"model": "permission.permission",
"pk": 23,
"fields": {
"model": [
"note",
"transaction"
],
"query": "{}",
"type": "change",
"mask": 1,
"field": "valid",
"description": "Update validation status of a transaction"
}
},
{
"model": "permission.permission",
"pk": 24,
"fields": {
"model": [
"note",
"transaction"
],
"query": "{}",
"type": "view",
"mask": 2,
"field": "",
"description": "View all transactions"
}
},
{
"model": "permission.permission",
"pk": 25,
"fields": {
"model": [
"note",
"notespecial"
],
"query": "{}",
"type": "view",
"mask": 2,
"field": "",
"description": "Display credit/debit interface"
}
},
{
"model": "permission.permission",
"pk": 26,
"fields": {
"model": [
"note",
"specialtransaction"
],
"query": "{}",
"type": "add",
"mask": 2,
"field": "",
"description": "Create credit/debit transaction"
}
},
{
"model": "permission.permission",
"pk": 27,
"fields": {
"model": [
"note",
"templatecategory"
],
"query": "{}",
"type": "view",
"mask": 2,
"field": "",
"description": "View button categories"
}
},
{
"model": "permission.permission",
"pk": 28,
"fields": {
"model": [
"note",
"templatecategory"
],
"query": "{}",
"type": "change",
"mask": 3,
"field": "",
"description": "Change button category"
}
},
{
"model": "permission.permission",
"pk": 29,
"fields": {
"model": [
"note",
"templatecategory"
],
"query": "{}",
"type": "add",
"mask": 3,
"field": "",
"description": "Add button category"
}
},
{
"model": "permission.permission",
"pk": 30,
"fields": {
"model": [
"note",
"transactiontemplate"
],
"query": "{}",
"type": "view",
"mask": 2,
"field": "",
"description": "View buttons"
}
},
{
"model": "permission.permission",
"pk": 31,
"fields": {
"model": [
"note",
"transactiontemplate"
],
"query": "{}",
"type": "add",
"mask": 3,
"field": "",
"description": "Add buttons"
}
},
{
"model": "permission.permission",
"pk": 32,
"fields": {
"model": [
"note",
"transactiontemplate"
],
"query": "{}",
"type": "change",
"mask": 3,
"field": "",
"description": "Update buttons"
}
},
{
"model": "permission.permission",
"pk": 33,
"fields": {
"model": [
"note",
"transaction"
],
"query": "{}",
"type": "add",
"mask": 2,
"field": "",
"description": "Create any transaction"
}
},
{
"model": "permission.rolepermissions",
"pk": 1,
"fields": {
"role": 1,
"permissions": [
1,
2,
7,
8,
9,
10,
11
]
}
},
{
"model": "permission.rolepermissions",
"pk": 2,
"fields": {
"role": 2,
"permissions": [
1,
2,
3,
4,
5,
6,
7,
8,
9,
10,
11,
12,
13,
14,
15,
16,
17,
18
]
}
},
{
"model": "permission.rolepermissions",
"pk": 3,
"fields": {
"role": 8,
"permissions": [
19,
20,
21,
22
]
}
},
{
"model": "permission.rolepermissions",
"pk": 4,
"fields": {
"role": 4,
"permissions": [
23,
24,
25,
26,
27,
28,
29,
30,
31,
32,
33
]
}
}
]

View File

282
apps/permission/models.py Normal file
View File

@ -0,0 +1,282 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
import functools
import json
import operator
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError
from django.db import models
from django.db.models import F, Q, Model
from django.utils.translation import gettext_lazy as _
from member.models import Role
class InstancedPermission:
def __init__(self, model, query, type, field, mask, **kwargs):
self.model = model
self.raw_query = query
self.query = None
self.type = type
self.field = field
self.mask = mask
self.kwargs = kwargs
def applies(self, obj, permission_type, field_name=None):
"""
Returns True if the permission applies to
the field `field_name` object `obj`
"""
if not isinstance(obj, self.model.model_class()):
# The permission does not apply to the model
return False
if self.type == 'add':
if permission_type == self.type:
self.update_query()
# Don't increase indexes
obj.pk = 0
# Force insertion, no data verification, no trigger
Model.save(obj, force_insert=True)
ret = obj in self.model.model_class().objects.filter(self.query).all()
# Delete testing object
Model.delete(obj)
return ret
if permission_type == self.type:
if self.field and field_name != self.field:
return False
self.update_query()
return obj in self.model.model_class().objects.filter(self.query).all()
else:
return False
def update_query(self):
"""
The query is not analysed in a first time. It is analysed at most once if needed.
:return:
"""
if not self.query:
# noinspection PyProtectedMember
self.query = Permission._about(self.raw_query, **self.kwargs)
def __repr__(self):
if self.field:
return _("Can {type} {model}.{field} in {query}").format(type=self.type, model=self.model, field=self.field, query=self.query)
else:
return _("Can {type} {model} in {query}").format(type=self.type, model=self.model, query=self.query)
def __str__(self):
return self.__repr__()
class PermissionMask(models.Model):
"""
Permissions that are hidden behind a mask
"""
rank = models.PositiveSmallIntegerField(
unique=True,
verbose_name=_('rank'),
)
description = models.CharField(
max_length=255,
unique=True,
verbose_name=_('description'),
)
def __str__(self):
return self.description
class Permission(models.Model):
PERMISSION_TYPES = [
('add', 'add'),
('view', 'view'),
('change', 'change'),
('delete', 'delete')
]
model = models.ForeignKey(ContentType, on_delete=models.CASCADE, related_name='+')
# A json encoded Q object with the following grammar
# query -> [] | {} (the empty query representing all objects)
# query -> ["AND", query, …] AND multiple queries
# | ["OR", query, …] OR multiple queries
# | ["NOT", query] Opposite of query
# query -> {key: value, …} A list of fields and values of a Q object
# key -> string A field name
# value -> int | string | bool | null Literal values
# | [parameter, …] A parameter. See compute_param for more details.
# | {"F": oper} An F object
# oper -> [string, …] A parameter. See compute_param for more details.
# | ["ADD", oper, …] Sum multiple F objects or literal
# | ["SUB", oper, oper] Substract two F objects or literal
# | ["MUL", oper, …] Multiply F objects or literals
# | int | string | bool | null Literal values
# | ["F", string] A field
#
# Examples:
# Q(is_superuser=True) := {"is_superuser": true}
# ~Q(is_superuser=True) := ["NOT", {"is_superuser": true}]
query = models.TextField()
type = models.CharField(max_length=15, choices=PERMISSION_TYPES)
mask = models.ForeignKey(
PermissionMask,
on_delete=models.PROTECT,
)
field = models.CharField(max_length=255, blank=True)
description = models.CharField(max_length=255, blank=True)
class Meta:
unique_together = ('model', 'query', 'type', 'field')
def clean(self):
self.query = json.dumps(json.loads(self.query))
if self.field and self.type not in {'view', 'change'}:
raise ValidationError(_("Specifying field applies only to view and change permission types."))
def save(self, **kwargs):
self.full_clean()
super().save()
@staticmethod
def compute_f(oper, **kwargs):
if isinstance(oper, list):
if oper[0] == 'ADD':
return functools.reduce(operator.add, [Permission.compute_f(oper, **kwargs) for oper in oper[1:]])
elif oper[0] == 'SUB':
return Permission.compute_f(oper[1], **kwargs) - Permission.compute_f(oper[2], **kwargs)
elif oper[0] == 'MUL':
return functools.reduce(operator.mul, [Permission.compute_f(oper, **kwargs) for oper in oper[1:]])
elif oper[0] == 'F':
return F(oper[1])
else:
field = kwargs[oper[0]]
for i in range(1, len(oper)):
field = getattr(field, oper[i])
return field
else:
return oper
@staticmethod
def compute_param(value, **kwargs):
"""
A parameter is given by a list. The first argument is the name of the parameter.
The parameters are the user, the club, and some classes (Note, ...)
If there are more arguments in the list, then attributes are queried.
For example, ["user", "note", "balance"] will return the balance of the note of the user.
If an argument is a list, then this is interpreted with a function call:
First argument is the name of the function, next arguments are parameters, and if there is a dict,
then the dict is given as kwargs.
For example: NoteUser.objects.filter(user__memberships__club__name="Kfet").all() is translated by:
["NoteUser", "objects", ["filter", {"user__memberships__club__name": "Kfet"}], ["all"]]
"""
if not isinstance(value, list):
return value
field = kwargs[value[0]]
for i in range(1, len(value)):
if isinstance(value[i], list):
if value[i][0] in kwargs:
field = Permission.compute_param(value[i], **kwargs)
continue
field = getattr(field, value[i][0])
params = []
call_kwargs = {}
for j in range(1, len(value[i])):
param = Permission.compute_param(value[i][j], **kwargs)
if isinstance(param, dict):
for key in param:
val = Permission.compute_param(param[key], **kwargs)
call_kwargs[key] = val
else:
params.append(param)
field = field(*params, **call_kwargs)
else:
field = getattr(field, value[i])
return field
@staticmethod
def _about(query, **kwargs):
"""
Translate JSON query into a Q query.
:param query: The JSON query
:param kwargs: Additional params
:return: A Q object
"""
if len(query) == 0:
# The query is either [] or {} and
# applies to all objects of the model
# to represent this we return a trivial request
return Q(pk=F("pk"))
if isinstance(query, list):
if query[0] == 'AND':
return functools.reduce(operator.and_, [Permission._about(query, **kwargs) for query in query[1:]])
elif query[0] == 'OR':
return functools.reduce(operator.or_, [Permission._about(query, **kwargs) for query in query[1:]])
elif query[0] == 'NOT':
return ~Permission._about(query[1], **kwargs)
else:
return Q(pk=F("pk"))
elif isinstance(query, dict):
q_kwargs = {}
for key in query:
value = query[key]
if isinstance(value, list):
# It is a parameter we query its return value
q_kwargs[key] = Permission.compute_param(value, **kwargs)
elif isinstance(value, dict):
# It is an F object
q_kwargs[key] = Permission.compute_f(value['F'], **kwargs)
else:
q_kwargs[key] = value
return Q(**q_kwargs)
else:
# TODO: find a better way to crash here
raise Exception("query {} is wrong".format(query))
def about(self, **kwargs):
"""
Return an InstancedPermission with the parameters
replaced by their values and the query interpreted
"""
query = json.loads(self.query)
# query = self._about(query, **kwargs)
return InstancedPermission(self.model, query, self.type, self.field, self.mask, **kwargs)
def __str__(self):
if self.field:
return _("Can {type} {model}.{field} in {query}").format(type=self.type, model=self.model, field=self.field, query=self.query)
else:
return _("Can {type} {model} in {query}").format(type=self.type, model=self.model, query=self.query)
class RolePermissions(models.Model):
"""
Permissions associated with a Role
"""
role = models.ForeignKey(
Role,
on_delete=models.PROTECT,
related_name='+',
verbose_name=_('role'),
)
permissions = models.ManyToManyField(
Permission,
)
def __str__(self):
return str(self.role)

View File

@ -0,0 +1,65 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from rest_framework.permissions import DjangoObjectPermissions
from .backends import PermissionBackend
SAFE_METHODS = ('HEAD', 'OPTIONS', )
class StrongDjangoObjectPermissions(DjangoObjectPermissions):
"""
Default DjangoObjectPermissions grant view permission to all.
This is a simple patch of this class that controls view access.
"""
perms_map = {
'GET': ['%(app_label)s.view_%(model_name)s'],
'OPTIONS': [],
'HEAD': [],
'POST': ['%(app_label)s.add_%(model_name)s'],
'PUT': ['%(app_label)s.change_%(model_name)s'],
'PATCH': ['%(app_label)s.change_%(model_name)s'],
'DELETE': ['%(app_label)s.delete_%(model_name)s'],
}
def get_required_object_permissions(self, method, model_cls):
kwargs = {
'app_label': model_cls._meta.app_label,
'model_name': model_cls._meta.model_name
}
if method not in self.perms_map:
from rest_framework import exceptions
raise exceptions.MethodNotAllowed(method)
return [perm % kwargs for perm in self.perms_map[method]]
def has_object_permission(self, request, view, obj):
# authentication checks have already executed via has_permission
queryset = self._queryset(view)
model_cls = queryset.model
user = request.user
perms = self.get_required_object_permissions(request.method, model_cls)
# if not user.has_perms(perms, obj):
if not all(PermissionBackend().has_perm(user, perm, obj) for perm in perms):
# If the user does not have permissions we need to determine if
# they have read permissions to see 403, or not, and simply see
# a 404 response.
from django.http import Http404
if request.method in SAFE_METHODS:
# Read permissions already checked and failed, no need
# to make another lookup.
raise Http404
read_perms = self.get_required_object_permissions('GET', model_cls)
if not user.has_perms(read_perms, obj):
raise Http404
# Has read permissions.
return False
return True

105
apps/permission/signals.py Normal file
View File

@ -0,0 +1,105 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from django.core.exceptions import PermissionDenied
from django.db.models.signals import pre_save, pre_delete, post_save, post_delete
from logs import signals as logs_signals
from note_kfet.middlewares import get_current_authenticated_user
from permission.backends import PermissionBackend
EXCLUDED = [
'cas_server.proxygrantingticket',
'cas_server.proxyticket',
'cas_server.serviceticket',
'cas_server.user',
'cas_server.userattributes',
'contenttypes.contenttype',
'logs.changelog',
'migrations.migration',
'sessions.session',
]
def pre_save_object(sender, instance, **kwargs):
"""
Before a model get saved, we check the permissions
"""
# noinspection PyProtectedMember
if instance._meta.label_lower in EXCLUDED:
return
user = get_current_authenticated_user()
if user is None:
# Action performed on shell is always granted
return
qs = sender.objects.filter(pk=instance.pk).all()
model_name_full = instance._meta.label_lower.split(".")
app_label = model_name_full[0]
model_name = model_name_full[1]
if qs.exists():
# We check if the user can change the model
# If the user has all right on a model, then OK
if PermissionBackend().has_perm(user, app_label + ".change_" + model_name, instance):
return
# In the other case, we check if he/she has the right to change one field
previous = qs.get()
for field in instance._meta.fields:
field_name = field.name
old_value = getattr(previous, field.name)
new_value = getattr(instance, field.name)
# If the field wasn't modified, no need to check the permissions
if old_value == new_value:
continue
if not PermissionBackend().has_perm(user, app_label + ".change_" + model_name + "_" + field_name, instance):
raise PermissionDenied
else:
# We check if the user can add the model
# While checking permissions, the object will be inserted in the DB, then removed.
# We disable temporary the connectors
pre_save.disconnect(pre_save_object)
pre_delete.disconnect(pre_delete_object)
# We disable also logs connectors
pre_save.disconnect(logs_signals.pre_save_object)
post_save.disconnect(logs_signals.save_object)
post_delete.disconnect(logs_signals.delete_object)
# We check if the user has right to add the object
has_perm = PermissionBackend().has_perm(user, app_label + ".add_" + model_name, instance)
# Then we reconnect all
pre_save.connect(pre_save_object)
pre_delete.connect(pre_delete_object)
pre_save.connect(logs_signals.pre_save_object)
post_save.connect(logs_signals.save_object)
post_delete.connect(logs_signals.delete_object)
if not has_perm:
raise PermissionDenied
def pre_delete_object(sender, instance, **kwargs):
"""
Before a model get deleted, we check the permissions
"""
# noinspection PyProtectedMember
if instance._meta.label_lower in EXCLUDED:
return
user = get_current_authenticated_user()
if user is None:
# Action performed on shell is always granted
return
model_name_full = instance._meta.label_lower.split(".")
app_label = model_name_full[0]
model_name = model_name_full[1]
# We check if the user has rights to delete the object
if not PermissionBackend().has_perm(user, app_label + ".delete_" + model_name, instance):
raise PermissionDenied

View File

View File

@ -0,0 +1,53 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from django.contrib.contenttypes.models import ContentType
from django.template.defaultfilters import stringfilter
from django import template
from note_kfet.middlewares import get_current_authenticated_user, get_current_session
from permission.backends import PermissionBackend
@stringfilter
def not_empty_model_list(model_name):
"""
Return True if and only if the current user has right to see any object of the given model.
"""
user = get_current_authenticated_user()
session = get_current_session()
if user is None:
return False
elif user.is_superuser and session.get("permission_mask", 0) >= 42:
return True
if session.get("not_empty_model_list_" + model_name, None):
return session.get("not_empty_model_list_" + model_name, None) == 1
spl = model_name.split(".")
ct = ContentType.objects.get(app_label=spl[0], model=spl[1])
qs = ct.model_class().objects.filter(PermissionBackend.filter_queryset(user, ct, "view")).all()
session["not_empty_model_list_" + model_name] = 1 if qs.exists() else 2
return session.get("not_empty_model_list_" + model_name) == 1
@stringfilter
def not_empty_model_change_list(model_name):
"""
Return True if and only if the current user has right to change any object of the given model.
"""
user = get_current_authenticated_user()
session = get_current_session()
if user is None:
return False
elif user.is_superuser and session.get("permission_mask", 0) >= 42:
return True
if session.get("not_empty_model_change_list_" + model_name, None):
return session.get("not_empty_model_change_list_" + model_name, None) == 1
spl = model_name.split(".")
ct = ContentType.objects.get(app_label=spl[0], model=spl[1])
qs = ct.model_class().objects.filter(PermissionBackend.filter_queryset(user, ct, "change"))
session["not_empty_model_change_list_" + model_name] = 1 if qs.exists() else 2
return session.get("not_empty_model_change_list_" + model_name) == 1
register = template.Library()
register.filter('not_empty_model_list', not_empty_model_list)
register.filter('not_empty_model_change_list', not_empty_model_change_list)

1
apps/scripts Submodule

@ -0,0 +1 @@
Subproject commit b9fdced3c2ce34168b8f0d6004a20a69ca16e0de

View File

@ -0,0 +1,4 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
default_app_config = 'treasury.apps.TreasuryConfig'

27
apps/treasury/admin.py Normal file
View File

@ -0,0 +1,27 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-lateré
from django.contrib import admin
from .models import RemittanceType, Remittance
@admin.register(RemittanceType)
class RemittanceTypeAdmin(admin.ModelAdmin):
"""
Admin customisation for RemiitanceType
"""
list_display = ('note', )
@admin.register(Remittance)
class RemittanceAdmin(admin.ModelAdmin):
"""
Admin customisation for Remittance
"""
list_display = ('remittance_type', 'date', 'comment', 'count', 'amount', 'closed', )
def has_change_permission(self, request, obj=None):
if not obj:
return True
return not obj.closed and super().has_change_permission(request, obj)

View File

View File

@ -0,0 +1,62 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from rest_framework import serializers
from note.api.serializers import SpecialTransactionSerializer
from ..models import Invoice, Product, RemittanceType, Remittance
class ProductSerializer(serializers.ModelSerializer):
"""
REST API Serializer for Product types.
The djangorestframework plugin will analyse the model `Product` and parse all fields in the API.
"""
class Meta:
model = Product
fields = '__all__'
class InvoiceSerializer(serializers.ModelSerializer):
"""
REST API Serializer for Invoice types.
The djangorestframework plugin will analyse the model `Invoice` and parse all fields in the API.
"""
class Meta:
model = Invoice
fields = '__all__'
read_only_fields = ('bde',)
products = serializers.SerializerMethodField()
def get_products(self, obj):
return serializers.ListSerializer(child=ProductSerializer())\
.to_representation(Product.objects.filter(invoice=obj).all())
class RemittanceTypeSerializer(serializers.ModelSerializer):
"""
REST API Serializer for RemittanceType types.
The djangorestframework plugin will analyse the model `RemittanceType` and parse all fields in the API.
"""
class Meta:
model = RemittanceType
fields = '__all__'
class RemittanceSerializer(serializers.ModelSerializer):
"""
REST API Serializer for Remittance types.
The djangorestframework plugin will analyse the model `Remittance` and parse all fields in the API.
"""
transactions = serializers.SerializerMethodField()
class Meta:
model = Remittance
fields = '__all__'
def get_transactions(self, obj):
return serializers.ListSerializer(child=SpecialTransactionSerializer()).to_representation(obj.transactions)

14
apps/treasury/api/urls.py Normal file
View File

@ -0,0 +1,14 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from .views import InvoiceViewSet, ProductViewSet, RemittanceViewSet, RemittanceTypeViewSet
def register_treasury_urls(router, path):
"""
Configure router for treasury REST API.
"""
router.register(path + '/invoice', InvoiceViewSet)
router.register(path + '/product', ProductViewSet)
router.register(path + '/remittance_type', RemittanceTypeViewSet)
router.register(path + '/remittance', RemittanceViewSet)

View File

@ -0,0 +1,53 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework.filters import SearchFilter
from api.viewsets import ReadProtectedModelViewSet
from .serializers import InvoiceSerializer, ProductSerializer, RemittanceTypeSerializer, RemittanceSerializer
from ..models import Invoice, Product, RemittanceType, Remittance
class InvoiceViewSet(ReadProtectedModelViewSet):
"""
REST API View set.
The djangorestframework plugin will get all `Invoice` objects, serialize it to JSON with the given serializer,
then render it on /api/treasury/invoice/
"""
queryset = Invoice.objects.all()
serializer_class = InvoiceSerializer
filter_backends = [DjangoFilterBackend]
filterset_fields = ['bde', ]
class ProductViewSet(ReadProtectedModelViewSet):
"""
REST API View set.
The djangorestframework plugin will get all `Product` objects, serialize it to JSON with the given serializer,
then render it on /api/treasury/product/
"""
queryset = Product.objects.all()
serializer_class = ProductSerializer
filter_backends = [SearchFilter]
search_fields = ['$designation', ]
class RemittanceTypeViewSet(ReadProtectedModelViewSet):
"""
REST API View set.
The djangorestframework plugin will get all `RemittanceType` objects, serialize it to JSON with the given serializer
then render it on /api/treasury/remittance_type/
"""
queryset = RemittanceType.objects.all()
serializer_class = RemittanceTypeSerializer
class RemittanceViewSet(ReadProtectedModelViewSet):
"""
REST API View set.
The djangorestframework plugin will get all `Remittance` objects, serialize it to JSON with the given serializer,
then render it on /api/treasury/remittance/
"""
queryset = Remittance.objects.all()
serializer_class = RemittanceSerializer

33
apps/treasury/apps.py Normal file
View File

@ -0,0 +1,33 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from django.apps import AppConfig
from django.db.models import Q
from django.db.models.signals import post_save, post_migrate
from django.utils.translation import gettext_lazy as _
class TreasuryConfig(AppConfig):
name = 'treasury'
verbose_name = _('Treasury')
def ready(self):
"""
Define app internal signals to interact with other apps
"""
from . import signals
from note.models import SpecialTransaction, NoteSpecial
from treasury.models import SpecialTransactionProxy
post_save.connect(signals.save_special_transaction, sender=SpecialTransaction)
def setup_specialtransactions_proxies(**kwargs):
# If the treasury app was disabled for any reason during a certain amount of time,
# we ensure that each special transaction is linked to a proxy
for transaction in SpecialTransaction.objects.filter(
source__in=NoteSpecial.objects.filter(~Q(remittancetype=None)),
specialtransactionproxy=None,
):
SpecialTransactionProxy.objects.create(transaction=transaction, remittance=None)
post_migrate.connect(setup_specialtransactions_proxies, sender=SpecialTransactionProxy)

View File

@ -0,0 +1,9 @@
[
{
"model": "treasury.remittancetype",
"pk": 1,
"fields": {
"note": 3
}
}
]

156
apps/treasury/forms.py Normal file
View File

@ -0,0 +1,156 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
import datetime
from crispy_forms.helper import FormHelper
from crispy_forms.layout import Submit
from django import forms
from django.utils.translation import gettext_lazy as _
from .models import Invoice, Product, Remittance, SpecialTransactionProxy
class InvoiceForm(forms.ModelForm):
"""
Create and generate invoices.
"""
# Django forms don't support date fields. We have to add it manually
date = forms.DateField(
initial=datetime.date.today,
widget=forms.TextInput(attrs={'type': 'date'})
)
def clean_date(self):
self.instance.date = self.data.get("date")
class Meta:
model = Invoice
exclude = ('bde', )
# Add a subform per product in the invoice form, and manage correctly the link between the invoice and
# its products. The FormSet will search automatically the ForeignKey in the Product model.
ProductFormSet = forms.inlineformset_factory(
Invoice,
Product,
fields='__all__',
extra=1,
)
class ProductFormSetHelper(FormHelper):
"""
Specify some template informations for the product form.
"""
def __init__(self, form=None):
super().__init__(form)
self.form_tag = False
self.form_method = 'POST'
self.form_class = 'form-inline'
self.template = 'bootstrap4/table_inline_formset.html'
class RemittanceForm(forms.ModelForm):
"""
Create remittances.
"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.helper = FormHelper()
# We can't update the type of the remittance once created.
if self.instance.pk:
self.fields["remittance_type"].disabled = True
self.fields["remittance_type"].required = False
# We display the submit button iff the remittance is open,
# the close button iff it is open and has a linked transaction
if not self.instance.closed:
self.helper.add_input(Submit('submit', _("Submit"), attr={'class': 'btn btn-block btn-primary'}))
if self.instance.transactions:
self.helper.add_input(Submit("close", _("Close"), css_class='btn btn-success'))
else:
# If the remittance is closed, we can't change anything
self.fields["comment"].disabled = True
self.fields["comment"].required = False
def clean(self):
# We can't update anything if the remittance is already closed.
if self.instance.closed:
self.add_error("comment", _("Remittance is already closed."))
cleaned_data = super().clean()
if self.instance.pk and cleaned_data.get("remittance_type") != self.instance.remittance_type:
self.add_error("remittance_type", _("You can't change the type of the remittance."))
# The close button is manually handled
if "close" in self.data:
self.instance.closed = True
self.cleaned_data["closed"] = True
return cleaned_data
class Meta:
model = Remittance
fields = ('remittance_type', 'comment',)
class LinkTransactionToRemittanceForm(forms.ModelForm):
"""
Attach a special transaction to a remittance.
"""
# Since we use a proxy model for special transactions, we add manually the fields related to the transaction
last_name = forms.CharField(label=_("Last name"))
first_name = forms.Field(label=_("First name"))
bank = forms.Field(label=_("Bank"))
amount = forms.IntegerField(label=_("Amount"), min_value=0)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.helper = FormHelper()
# Add submit button
self.helper.add_input(Submit('submit', _("Submit"), attr={'class': 'btn btn-block btn-primary'}))
self.fields["remittance"].queryset = Remittance.objects.filter(closed=False)
def clean_last_name(self):
"""
Replace the first name in the information of the transaction.
"""
self.instance.transaction.last_name = self.data.get("last_name")
self.instance.transaction.clean()
def clean_first_name(self):
"""
Replace the last name in the information of the transaction.
"""
self.instance.transaction.first_name = self.data.get("first_name")
self.instance.transaction.clean()
def clean_bank(self):
"""
Replace the bank in the information of the transaction.
"""
self.instance.transaction.bank = self.data.get("bank")
self.instance.transaction.clean()
def clean_amount(self):
"""
Replace the amount of the transaction.
"""
self.instance.transaction.amount = self.data.get("amount")
self.instance.transaction.clean()
class Meta:
model = SpecialTransactionProxy
fields = ('remittance', )

View File

189
apps/treasury/models.py Normal file
View File

@ -0,0 +1,189 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from django.core.exceptions import ValidationError
from django.db import models
from django.db.models import Q
from django.utils.translation import gettext_lazy as _
from note.models import NoteSpecial, SpecialTransaction
class Invoice(models.Model):
"""
An invoice model that can generates a true invoice.
"""
id = models.PositiveIntegerField(
primary_key=True,
verbose_name=_("Invoice identifier"),
)
bde = models.CharField(
max_length=32,
default='Saperlistpopette.png',
choices=(
('Saperlistpopette.png', 'Saper[list]popette'),
('Finalist.png', 'Fina[list]'),
('Listorique.png', '[List]orique'),
('Satellist.png', 'Satel[list]'),
('Monopolist.png', 'Monopo[list]'),
('Kataclist.png', 'Katac[list]'),
),
verbose_name=_("BDE"),
)
object = models.CharField(
max_length=255,
verbose_name=_("Object"),
)
description = models.TextField(
verbose_name=_("Description")
)
name = models.CharField(
max_length=255,
verbose_name=_("Name"),
)
address = models.TextField(
verbose_name=_("Address"),
)
date = models.DateField(
auto_now_add=True,
verbose_name=_("Place"),
)
acquitted = models.BooleanField(
verbose_name=_("Acquitted"),
)
class Product(models.Model):
"""
Product that appears on an invoice.
"""
invoice = models.ForeignKey(
Invoice,
on_delete=models.PROTECT,
)
designation = models.CharField(
max_length=255,
verbose_name=_("Designation"),
)
quantity = models.PositiveIntegerField(
verbose_name=_("Quantity")
)
amount = models.IntegerField(
verbose_name=_("Unit price")
)
@property
def amount_euros(self):
return self.amount / 100
@property
def total(self):
return self.quantity * self.amount
@property
def total_euros(self):
return self.total / 100
class RemittanceType(models.Model):
"""
Store what kind of remittances can be stored.
"""
note = models.OneToOneField(
NoteSpecial,
on_delete=models.CASCADE,
)
def __str__(self):
return str(self.note)
class Remittance(models.Model):
"""
Treasurers want to regroup checks or bank transfers in bank remittances.
"""
date = models.DateTimeField(
auto_now_add=True,
verbose_name=_("Date"),
)
remittance_type = models.ForeignKey(
RemittanceType,
on_delete=models.PROTECT,
verbose_name=_("Type"),
)
comment = models.CharField(
max_length=255,
verbose_name=_("Comment"),
)
closed = models.BooleanField(
default=False,
verbose_name=_("Closed"),
)
@property
def transactions(self):
"""
:return: Transactions linked to this remittance.
"""
if not self.pk:
return SpecialTransaction.objects.none()
return SpecialTransaction.objects.filter(specialtransactionproxy__remittance=self)
def count(self):
"""
Linked transactions count.
"""
return self.transactions.count()
@property
def amount(self):
"""
Total amount of the remittance.
"""
return sum(transaction.total for transaction in self.transactions.all())
def save(self, force_insert=False, force_update=False, using=None, update_fields=None):
# Check if all transactions have the right type.
if self.transactions.filter(~Q(source=self.remittance_type.note)).exists():
raise ValidationError("All transactions in a remittance must have the same type")
return super().save(force_insert, force_update, using, update_fields)
def __str__(self):
return _("Remittance #{:d}: {}").format(self.id, self.comment, )
class SpecialTransactionProxy(models.Model):
"""
In order to keep modularity, we don't that the Note app depends on the treasury app.
That's why we create a proxy in this app, to link special transactions and remittances.
If it isn't very clean, that makes what we want.
"""
transaction = models.OneToOneField(
SpecialTransaction,
on_delete=models.CASCADE,
)
remittance = models.ForeignKey(
Remittance,
on_delete=models.PROTECT,
null=True,
verbose_name=_("Remittance"),
)

12
apps/treasury/signals.py Normal file
View File

@ -0,0 +1,12 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from treasury.models import SpecialTransactionProxy, RemittanceType
def save_special_transaction(instance, created, **kwargs):
"""
When a special transaction is created, we create its linked proxy
"""
if created and RemittanceType.objects.filter(note=instance.source).exists():
SpecialTransactionProxy.objects.create(transaction=instance, remittance=None).save()

103
apps/treasury/tables.py Normal file
View File

@ -0,0 +1,103 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
import django_tables2 as tables
from django.utils.translation import gettext_lazy as _
from django_tables2 import A
from note.models import SpecialTransaction
from note.templatetags.pretty_money import pretty_money
from .models import Invoice, Remittance
class InvoiceTable(tables.Table):
"""
List all invoices.
"""
id = tables.LinkColumn("treasury:invoice_update",
args=[A("pk")],
text=lambda record: _("Invoice #{:d}").format(record.id), )
invoice = tables.LinkColumn("treasury:invoice_render",
verbose_name=_("Invoice"),
args=[A("pk")],
accessor="pk",
text="",
attrs={
'a': {'class': 'fa fa-file-pdf-o'},
'td': {'data-turbolinks': 'false'}
})
class Meta:
attrs = {
'class': 'table table-condensed table-striped table-hover'
}
model = Invoice
template_name = 'django_tables2/bootstrap4.html'
fields = ('id', 'name', 'object', 'acquitted', 'invoice',)
class RemittanceTable(tables.Table):
"""
List all remittances.
"""
count = tables.Column(verbose_name=_("Transaction count"))
amount = tables.Column(verbose_name=_("Amount"))
view = tables.LinkColumn("treasury:remittance_update",
verbose_name=_("View"),
args=[A("pk")],
text=_("View"),
attrs={
'a': {'class': 'btn btn-primary'}
}, )
def render_amount(self, value):
return pretty_money(value)
class Meta:
attrs = {
'class': 'table table-condensed table-striped table-hover'
}
model = Remittance
template_name = 'django_tables2/bootstrap4.html'
fields = ('id', 'date', 'remittance_type', 'comment', 'count', 'amount', 'view',)
class SpecialTransactionTable(tables.Table):
"""
List special credit transactions that are (or not, following the queryset) attached to a remittance.
"""
# Display add and remove buttons. Use the `exclude` field to select what is needed.
remittance_add = tables.LinkColumn("treasury:link_transaction",
verbose_name=_("Remittance"),
args=[A("specialtransactionproxy.pk")],
text=_("Add"),
attrs={
'a': {'class': 'btn btn-primary'}
}, )
remittance_remove = tables.LinkColumn("treasury:unlink_transaction",
verbose_name=_("Remittance"),
args=[A("specialtransactionproxy.pk")],
text=_("Remove"),
attrs={
'a': {'class': 'btn btn-primary btn-danger'}
}, )
def render_id(self, record):
return record.specialtransactionproxy.pk
def render_amount(self, value):
return pretty_money(value)
class Meta:
attrs = {
'class': 'table table-condensed table-striped table-hover'
}
model = SpecialTransaction
template_name = 'django_tables2/bootstrap4.html'
fields = ('id', 'source', 'destination', 'last_name', 'first_name', 'bank', 'amount', 'reason',)

24
apps/treasury/urls.py Normal file
View File

@ -0,0 +1,24 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from django.urls import path
from .views import InvoiceCreateView, InvoiceListView, InvoiceUpdateView, InvoiceRenderView, RemittanceListView,\
RemittanceCreateView, RemittanceUpdateView, LinkTransactionToRemittanceView, UnlinkTransactionToRemittanceView
app_name = 'treasury'
urlpatterns = [
# Invoice app paths
path('invoice/', InvoiceListView.as_view(), name='invoice_list'),
path('invoice/create/', InvoiceCreateView.as_view(), name='invoice_create'),
path('invoice/<int:pk>/', InvoiceUpdateView.as_view(), name='invoice_update'),
path('invoice/render/<int:pk>/', InvoiceRenderView.as_view(), name='invoice_render'),
# Remittance app paths
path('remittance/', RemittanceListView.as_view(), name='remittance_list'),
path('remittance/create/', RemittanceCreateView.as_view(), name='remittance_create'),
path('remittance/<int:pk>/', RemittanceUpdateView.as_view(), name='remittance_update'),
path('remittance/link_transaction/<int:pk>/', LinkTransactionToRemittanceView.as_view(), name='link_transaction'),
path('remittance/unlink_transaction/<int:pk>/', UnlinkTransactionToRemittanceView.as_view(),
name='unlink_transaction'),
]

316
apps/treasury/views.py Normal file
View File

@ -0,0 +1,316 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
import os
import shutil
import subprocess
from tempfile import mkdtemp
from crispy_forms.helper import FormHelper
from django.contrib.auth.mixins import LoginRequiredMixin
from django.core.exceptions import ValidationError
from django.db.models import Q
from django.http import HttpResponse
from django.shortcuts import redirect
from django.template.loader import render_to_string
from django.urls import reverse_lazy
from django.views.generic import CreateView, UpdateView
from django.views.generic.base import View, TemplateView
from django_tables2 import SingleTableView
from note.models import SpecialTransaction, NoteSpecial
from note_kfet.settings.base import BASE_DIR
from .forms import InvoiceForm, ProductFormSet, ProductFormSetHelper, RemittanceForm, LinkTransactionToRemittanceForm
from .models import Invoice, Product, Remittance, SpecialTransactionProxy
from .tables import InvoiceTable, RemittanceTable, SpecialTransactionTable
class InvoiceCreateView(LoginRequiredMixin, CreateView):
"""
Create Invoice
"""
model = Invoice
form_class = InvoiceForm
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
form = context['form']
form.helper = FormHelper()
# Remove form tag on the generation of the form in the template (already present on the template)
form.helper.form_tag = False
# The formset handles the set of the products
form_set = ProductFormSet(instance=form.instance)
context['formset'] = form_set
context['helper'] = ProductFormSetHelper()
context['no_cache'] = True
return context
def form_valid(self, form):
ret = super().form_valid(form)
kwargs = {}
# The user type amounts in cents. We convert it in euros.
for key in self.request.POST:
value = self.request.POST[key]
if key.endswith("amount") and value:
kwargs[key] = str(int(100 * float(value)))
elif value:
kwargs[key] = value
# For each product, we save it
formset = ProductFormSet(kwargs, instance=form.instance)
if formset.is_valid():
for f in formset:
# We don't save the product if the designation is not entered, ie. if the line is empty
if f.is_valid() and f.instance.designation:
f.save()
f.instance.save()
else:
f.instance = None
return ret
def get_success_url(self):
return reverse_lazy('treasury:invoice_list')
class InvoiceListView(LoginRequiredMixin, SingleTableView):
"""
List existing Invoices
"""
model = Invoice
table_class = InvoiceTable
class InvoiceUpdateView(LoginRequiredMixin, UpdateView):
"""
Create Invoice
"""
model = Invoice
form_class = InvoiceForm
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
form = context['form']
form.helper = FormHelper()
# Remove form tag on the generation of the form in the template (already present on the template)
form.helper.form_tag = False
# Fill the intial value for the date field, with the initial date of the model instance
form.fields['date'].initial = form.instance.date
# The formset handles the set of the products
form_set = ProductFormSet(instance=form.instance)
context['formset'] = form_set
context['helper'] = ProductFormSetHelper()
context['no_cache'] = True
return context
def form_valid(self, form):
ret = super().form_valid(form)
kwargs = {}
# The user type amounts in cents. We convert it in euros.
for key in self.request.POST:
value = self.request.POST[key]
if key.endswith("amount") and value:
kwargs[key] = str(int(100 * float(value)))
elif value:
kwargs[key] = value
formset = ProductFormSet(kwargs, instance=form.instance)
saved = []
# For each product, we save it
if formset.is_valid():
for f in formset:
# We don't save the product if the designation is not entered, ie. if the line is empty
if f.is_valid() and f.instance.designation:
f.save()
f.instance.save()
saved.append(f.instance.pk)
else:
f.instance = None
# Remove old products that weren't given in the form
Product.objects.filter(~Q(pk__in=saved), invoice=form.instance).delete()
return ret
def get_success_url(self):
return reverse_lazy('treasury:invoice_list')
class InvoiceRenderView(LoginRequiredMixin, View):
"""
Render Invoice as a generated PDF with the given information and a LaTeX template
"""
def get(self, request, **kwargs):
pk = kwargs["pk"]
invoice = Invoice.objects.get(pk=pk)
products = Product.objects.filter(invoice=invoice).all()
# Informations of the BDE. Should be updated when the school will move.
invoice.place = "Cachan"
invoice.my_name = "BDE ENS Cachan"
invoice.my_address_street = "61 avenue du Président Wilson"
invoice.my_city = "94230 Cachan"
invoice.bank_code = 30003
invoice.desk_code = 3894
invoice.account_number = 37280662
invoice.rib_key = 14
invoice.bic = "SOGEFRPP"
# Replace line breaks with the LaTeX equivalent
invoice.description = invoice.description.replace("\r", "").replace("\n", "\\\\ ")
invoice.address = invoice.address.replace("\r", "").replace("\n", "\\\\ ")
# Fill the template with the information
tex = render_to_string("treasury/invoice_sample.tex", dict(obj=invoice, products=products))
try:
os.mkdir(BASE_DIR + "/tmp")
except FileExistsError:
pass
# We render the file in a temporary directory
tmp_dir = mkdtemp(prefix=BASE_DIR + "/tmp/")
try:
with open("{}/invoice-{:d}.tex".format(tmp_dir, pk), "wb") as f:
f.write(tex.encode("UTF-8"))
del tex
# The file has to be rendered twice
for _ in range(2):
error = subprocess.Popen(
["pdflatex", "invoice-{}.tex".format(pk)],
cwd=tmp_dir,
stdin=open(os.devnull, "r"),
stderr=open(os.devnull, "wb"),
stdout=open(os.devnull, "wb"),
).wait()
if error:
raise IOError("An error attempted while generating a invoice (code=" + str(error) + ")")
# Display the generated pdf as a HTTP Response
pdf = open("{}/invoice-{}.pdf".format(tmp_dir, pk), 'rb').read()
response = HttpResponse(pdf, content_type="application/pdf")
response['Content-Disposition'] = "inline;filename=invoice-{:d}.pdf".format(pk)
except IOError as e:
raise e
finally:
# Delete all temporary files
shutil.rmtree(tmp_dir)
return response
class RemittanceCreateView(LoginRequiredMixin, CreateView):
"""
Create Remittance
"""
model = Remittance
form_class = RemittanceForm
def get_success_url(self):
return reverse_lazy('treasury:remittance_list')
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx["table"] = RemittanceTable(data=Remittance.objects.all())
ctx["special_transactions"] = SpecialTransactionTable(data=SpecialTransaction.objects.none())
return ctx
class RemittanceListView(LoginRequiredMixin, TemplateView):
"""
List existing Remittances
"""
template_name = "treasury/remittance_list.html"
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx["opened_remittances"] = RemittanceTable(data=Remittance.objects.filter(closed=False).all())
ctx["closed_remittances"] = RemittanceTable(data=Remittance.objects.filter(closed=True).reverse().all())
ctx["special_transactions_no_remittance"] = SpecialTransactionTable(
data=SpecialTransaction.objects.filter(source__in=NoteSpecial.objects.filter(~Q(remittancetype=None)),
specialtransactionproxy__remittance=None).all(),
exclude=('remittance_remove', ))
ctx["special_transactions_with_remittance"] = SpecialTransactionTable(
data=SpecialTransaction.objects.filter(source__in=NoteSpecial.objects.filter(~Q(remittancetype=None)),
specialtransactionproxy__remittance__closed=False).all(),
exclude=('remittance_add', ))
return ctx
class RemittanceUpdateView(LoginRequiredMixin, UpdateView):
"""
Update Remittance
"""
model = Remittance
form_class = RemittanceForm
def get_success_url(self):
return reverse_lazy('treasury:remittance_list')
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx["table"] = RemittanceTable(data=Remittance.objects.all())
data = SpecialTransaction.objects.filter(specialtransactionproxy__remittance=self.object).all()
ctx["special_transactions"] = SpecialTransactionTable(
data=data,
exclude=('remittance_add', 'remittance_remove', ) if self.object.closed else ('remittance_add', ))
return ctx
class LinkTransactionToRemittanceView(LoginRequiredMixin, UpdateView):
"""
Attach a special transaction to a remittance
"""
model = SpecialTransactionProxy
form_class = LinkTransactionToRemittanceForm
def get_success_url(self):
return reverse_lazy('treasury:remittance_list')
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
form = ctx["form"]
form.fields["last_name"].initial = self.object.transaction.last_name
form.fields["first_name"].initial = self.object.transaction.first_name
form.fields["bank"].initial = self.object.transaction.bank
form.fields["amount"].initial = self.object.transaction.amount
form.fields["remittance"].queryset = form.fields["remittance"] \
.queryset.filter(remittance_type__note=self.object.transaction.source)
return ctx
class UnlinkTransactionToRemittanceView(LoginRequiredMixin, View):
"""
Unlink a special transaction and its remittance
"""
def get(self, *args, **kwargs):
pk = kwargs["pk"]
transaction = SpecialTransactionProxy.objects.get(pk=pk)
# The remittance must be open (or inexistant)
if transaction.remittance and transaction.remittance.closed:
raise ValidationError("Remittance is already closed.")
transaction.remittance = None
transaction.save()
return redirect('treasury:remittance_list')

View File

@ -2,12 +2,17 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
if [ -z ${NOTE_URL+x} ]; then
echo "Warning: your env files are not configurated."
else
sed -i -e "s/example.com/$DOMAIN/g" /code/apps/member/fixtures/initial.json
sed -i -e "s/localhost/$NOTE_URL/g" /code/note_kfet/fixtures/initial.json
sed -i -e "s/\"\.\*\"/\"https?:\/\/$NOTE_URL\/.*\"/g" /code/note_kfet/fixtures/cas.json
sed -i -e "s/REPLACEME/La Note Kfet \\\\ud83c\\\\udf7b/g" /code/note_kfet/fixtures/cas.json
fi
python manage.py compilemessages
python manage.py makemigrations
# Wait for database
sleep 5
python manage.py migrate
# TODO: use uwsgi in production
python manage.py runserver 0.0.0.0:8000

View File

@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2020-02-27 17:39+0100\n"
"POT-Creation-Date: 2020-03-24 15:49+0100\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@ -23,9 +23,10 @@ msgid "activity"
msgstr ""
#: apps/activity/models.py:19 apps/activity/models.py:44
#: apps/member/models.py:60 apps/member/models.py:111
#: apps/note/models/notes.py:184 apps/note/models/transactions.py:24
#: apps/note/models/transactions.py:44 templates/member/profile_detail.html:11
#: apps/member/models.py:63 apps/member/models.py:114
#: apps/note/models/notes.py:188 apps/note/models/transactions.py:24
#: apps/note/models/transactions.py:44 apps/note/models/transactions.py:198
#: templates/member/profile_detail.html:15
msgid "name"
msgstr ""
@ -46,10 +47,11 @@ msgid "activity types"
msgstr ""
#: apps/activity/models.py:48 apps/note/models/transactions.py:69
#: apps/permission/models.py:90
msgid "description"
msgstr ""
#: apps/activity/models.py:54 apps/note/models/notes.py:160
#: apps/activity/models.py:54 apps/note/models/notes.py:164
#: apps/note/models/transactions.py:62
msgid "type"
msgstr ""
@ -82,227 +84,291 @@ msgstr ""
msgid "guests"
msgstr ""
#: apps/member/apps.py:10
#: apps/api/apps.py:10
msgid "API"
msgstr ""
#: apps/logs/apps.py:11
msgid "Logs"
msgstr ""
#: apps/logs/models.py:21 apps/note/models/notes.py:117
msgid "user"
msgstr ""
#: apps/logs/models.py:27
msgid "IP Address"
msgstr ""
#: apps/logs/models.py:35
msgid "model"
msgstr ""
#: apps/logs/models.py:42
msgid "identifier"
msgstr ""
#: apps/logs/models.py:47
msgid "previous data"
msgstr ""
#: apps/logs/models.py:52
msgid "new data"
msgstr ""
#: apps/logs/models.py:60
msgid "create"
msgstr ""
#: apps/logs/models.py:61
msgid "edit"
msgstr ""
#: apps/logs/models.py:62
msgid "delete"
msgstr ""
#: apps/logs/models.py:65
msgid "action"
msgstr ""
#: apps/logs/models.py:73
msgid "timestamp"
msgstr ""
#: apps/logs/models.py:77
msgid "Logs cannot be destroyed."
msgstr ""
#: apps/member/apps.py:14
msgid "member"
msgstr ""
#: apps/member/models.py:23
#: apps/member/models.py:25
msgid "phone number"
msgstr ""
#: apps/member/models.py:29 templates/member/profile_detail.html:24
#: apps/member/models.py:31 templates/member/profile_detail.html:28
msgid "section"
msgstr ""
#: apps/member/models.py:30
#: apps/member/models.py:32
msgid "e.g. \"1A0\", \"9A♥\", \"SAPHIRE\""
msgstr ""
#: apps/member/models.py:36 templates/member/profile_detail.html:27
#: apps/member/models.py:38 templates/member/profile_detail.html:31
msgid "address"
msgstr ""
#: apps/member/models.py:42
#: apps/member/models.py:44
msgid "paid"
msgstr ""
#: apps/member/models.py:47 apps/member/models.py:48
#: apps/member/models.py:49 apps/member/models.py:50
msgid "user profile"
msgstr ""
#: apps/member/models.py:65
#: apps/member/models.py:68
msgid "email"
msgstr ""
#: apps/member/models.py:70
#: apps/member/models.py:73
msgid "membership fee"
msgstr ""
#: apps/member/models.py:74
#: apps/member/models.py:77
msgid "membership duration"
msgstr ""
#: apps/member/models.py:75
#: apps/member/models.py:78
msgid "The longest time a membership can last (NULL = infinite)."
msgstr ""
#: apps/member/models.py:80
#: apps/member/models.py:83
msgid "membership start"
msgstr ""
#: apps/member/models.py:81
#: apps/member/models.py:84
msgid "How long after January 1st the members can renew their membership."
msgstr ""
#: apps/member/models.py:86
#: apps/member/models.py:89
msgid "membership end"
msgstr ""
#: apps/member/models.py:87
#: apps/member/models.py:90
msgid ""
"How long the membership can last after January 1st of the next year after "
"members can renew their membership."
msgstr ""
#: apps/member/models.py:93 apps/note/models/notes.py:135
#: apps/member/models.py:96 apps/note/models/notes.py:139
msgid "club"
msgstr ""
#: apps/member/models.py:94
#: apps/member/models.py:97
msgid "clubs"
msgstr ""
#: apps/member/models.py:117
#: apps/member/models.py:120 apps/permission/models.py:275
msgid "role"
msgstr ""
#: apps/member/models.py:118
#: apps/member/models.py:121
msgid "roles"
msgstr ""
#: apps/member/models.py:142
#: apps/member/models.py:145
msgid "membership starts on"
msgstr ""
#: apps/member/models.py:145
#: apps/member/models.py:148
msgid "membership ends on"
msgstr ""
#: apps/member/models.py:149
#: apps/member/models.py:152
msgid "fee"
msgstr ""
#: apps/member/models.py:153
#: apps/member/models.py:162
msgid "membership"
msgstr ""
#: apps/member/models.py:154
#: apps/member/models.py:163
msgid "memberships"
msgstr ""
#: apps/member/views.py:63 templates/member/profile_detail.html:42
#: apps/member/views.py:80 templates/member/profile_detail.html:46
msgid "Update Profile"
msgstr ""
#: apps/member/views.py:79
#: apps/member/views.py:93
msgid "An alias with a similar name already exists."
msgstr ""
#: apps/member/views.py:130
#: apps/member/views.py:146
#, python-format
msgid "Account #%(id)s: %(username)s"
msgstr ""
#: apps/note/admin.py:120 apps/note/models/transactions.py:93
#: apps/member/views.py:216
msgid "Alias successfully deleted"
msgstr ""
#: apps/note/admin.py:120 apps/note/models/transactions.py:94
msgid "source"
msgstr ""
#: apps/note/admin.py:128 apps/note/admin.py:156
#: apps/note/models/transactions.py:53 apps/note/models/transactions.py:99
#: apps/note/models/transactions.py:53 apps/note/models/transactions.py:100
msgid "destination"
msgstr ""
#: apps/note/apps.py:14 apps/note/models/notes.py:54
#: apps/note/apps.py:14 apps/note/models/notes.py:58
msgid "note"
msgstr ""
#: apps/note/forms.py:49
msgid "Source and destination must be different."
#: apps/note/forms.py:20
msgid "New Alias"
msgstr ""
#: apps/note/models/notes.py:26
msgid "account balance"
#: apps/note/forms.py:25
msgid "select an image"
msgstr ""
#: apps/note/forms.py:26
msgid "Maximal size: 2MB"
msgstr ""
#: apps/note/models/notes.py:27
msgid "account balance"
msgstr ""
#: apps/note/models/notes.py:28
msgid "in centimes, money credited for this instance"
msgstr ""
#: apps/note/models/notes.py:31
#: apps/note/models/notes.py:32
msgid "last negative date"
msgstr ""
#: apps/note/models/notes.py:32
#: apps/note/models/notes.py:33
msgid "last time the balance was negative"
msgstr ""
#: apps/note/models/notes.py:37
#: apps/note/models/notes.py:38
msgid "active"
msgstr ""
#: apps/note/models/notes.py:40
#: apps/note/models/notes.py:41
msgid ""
"Designates whether this note should be treated as active. Unselect this "
"instead of deleting notes."
msgstr ""
#: apps/note/models/notes.py:44
#: apps/note/models/notes.py:45
msgid "display image"
msgstr ""
#: apps/note/models/notes.py:49 apps/note/models/transactions.py:102
#: apps/note/models/notes.py:53 apps/note/models/transactions.py:103
msgid "created at"
msgstr ""
#: apps/note/models/notes.py:55
#: apps/note/models/notes.py:59
msgid "notes"
msgstr ""
#: apps/note/models/notes.py:63
#: apps/note/models/notes.py:67
msgid "Note"
msgstr ""
#: apps/note/models/notes.py:73 apps/note/models/notes.py:97
#: apps/note/models/notes.py:77 apps/note/models/notes.py:101
msgid "This alias is already taken."
msgstr ""
#: apps/note/models/notes.py:113
msgid "user"
msgstr ""
#: apps/note/models/notes.py:117
#: apps/note/models/notes.py:121
msgid "one's note"
msgstr ""
#: apps/note/models/notes.py:118
#: apps/note/models/notes.py:122
msgid "users note"
msgstr ""
#: apps/note/models/notes.py:124
#: apps/note/models/notes.py:128
#, python-format
msgid "%(user)s's note"
msgstr ""
#: apps/note/models/notes.py:139
#: apps/note/models/notes.py:143
msgid "club note"
msgstr ""
#: apps/note/models/notes.py:140
#: apps/note/models/notes.py:144
msgid "clubs notes"
msgstr ""
#: apps/note/models/notes.py:146
#: apps/note/models/notes.py:150
#, python-format
msgid "Note of %(club)s club"
msgstr ""
#: apps/note/models/notes.py:166
#: apps/note/models/notes.py:170
msgid "special note"
msgstr ""
#: apps/note/models/notes.py:167
#: apps/note/models/notes.py:171
msgid "special notes"
msgstr ""
#: apps/note/models/notes.py:190
#: apps/note/models/notes.py:194
msgid "Invalid alias"
msgstr ""
#: apps/note/models/notes.py:206
#: apps/note/models/notes.py:210
msgid "alias"
msgstr ""
#: apps/note/models/notes.py:207 templates/member/profile_detail.html:33
#: apps/note/models/notes.py:211 templates/member/profile_detail.html:37
msgid "aliases"
msgstr ""
@ -311,10 +377,10 @@ msgid "Alias is too long."
msgstr ""
#: apps/note/models/notes.py:238
msgid "An alias with a similar name already exists:"
msgid "An alias with a similar name already exists: {} "
msgstr ""
#: apps/note/models/notes.py:246
#: apps/note/models/notes.py:247
msgid "You can't delete your main alias."
msgstr ""
@ -330,7 +396,7 @@ msgstr ""
msgid "A template with this name already exist"
msgstr ""
#: apps/note/models/transactions.py:56 apps/note/models/transactions.py:109
#: apps/note/models/transactions.py:56 apps/note/models/transactions.py:111
msgid "amount"
msgstr ""
@ -338,59 +404,245 @@ msgstr ""
msgid "in centimes"
msgstr ""
#: apps/note/models/transactions.py:74
#: apps/note/models/transactions.py:75
msgid "transaction template"
msgstr ""
#: apps/note/models/transactions.py:75
#: apps/note/models/transactions.py:76
msgid "transaction templates"
msgstr ""
#: apps/note/models/transactions.py:106
#: apps/note/models/transactions.py:107
msgid "quantity"
msgstr ""
#: apps/note/models/transactions.py:111
#: apps/note/models/transactions.py:115
msgid "reason"
msgstr ""
#: apps/note/models/transactions.py:115
#: apps/note/models/transactions.py:119
msgid "valid"
msgstr ""
#: apps/note/models/transactions.py:120
#: apps/note/models/transactions.py:124
msgid "transaction"
msgstr ""
#: apps/note/models/transactions.py:121
#: apps/note/models/transactions.py:125
msgid "transactions"
msgstr ""
#: apps/note/models/transactions.py:184
#: apps/note/models/transactions.py:168 templates/base.html:98
#: templates/note/transaction_form.html:19
#: templates/note/transaction_form.html:145
msgid "Transfer"
msgstr ""
#: apps/note/models/transactions.py:188
msgid "Template"
msgstr ""
#: apps/note/models/transactions.py:203
msgid "first_name"
msgstr ""
#: apps/note/models/transactions.py:208
msgid "bank"
msgstr ""
#: apps/note/models/transactions.py:214 templates/note/transaction_form.html:24
msgid "Credit"
msgstr ""
#: apps/note/models/transactions.py:214 templates/note/transaction_form.html:28
msgid "Debit"
msgstr ""
#: apps/note/models/transactions.py:230 apps/note/models/transactions.py:235
msgid "membership transaction"
msgstr ""
#: apps/note/models/transactions.py:185
#: apps/note/models/transactions.py:231
msgid "membership transactions"
msgstr ""
#: apps/note/views.py:29
msgid "Transfer money from your account to one or others"
#: apps/note/views.py:39
msgid "Transfer money"
msgstr ""
#: apps/note/views.py:138
msgid "Consommations"
#: apps/note/views.py:145 templates/base.html:79
msgid "Consumptions"
msgstr ""
#: note_kfet/settings/base.py:155
#: apps/permission/models.py:69 apps/permission/models.py:262
#, python-brace-format
msgid "Can {type} {model}.{field} in {query}"
msgstr ""
#: apps/permission/models.py:71 apps/permission/models.py:264
#, python-brace-format
msgid "Can {type} {model} in {query}"
msgstr ""
#: apps/permission/models.py:84
msgid "rank"
msgstr ""
#: apps/permission/models.py:147
msgid "Specifying field applies only to view and change permission types."
msgstr ""
#: apps/treasury/apps.py:11 templates/base.html:102
msgid "Treasury"
msgstr ""
#: apps/treasury/forms.py:56 apps/treasury/forms.py:95
#: templates/django_filters/rest_framework/form.html:5
#: templates/member/club_form.html:10 templates/treasury/invoice_form.html:47
msgid "Submit"
msgstr ""
#: apps/treasury/forms.py:58
msgid "Close"
msgstr ""
#: apps/treasury/forms.py:65
msgid "Remittance is already closed."
msgstr ""
#: apps/treasury/forms.py:70
msgid "You can't change the type of the remittance."
msgstr ""
#: apps/treasury/forms.py:84
msgid "Last name"
msgstr ""
#: apps/treasury/forms.py:86 templates/note/transaction_form.html:92
msgid "First name"
msgstr ""
#: apps/treasury/forms.py:88 templates/note/transaction_form.html:98
msgid "Bank"
msgstr ""
#: apps/treasury/forms.py:90 apps/treasury/tables.py:40
#: templates/note/transaction_form.html:128
#: templates/treasury/remittance_form.html:18
msgid "Amount"
msgstr ""
#: apps/treasury/models.py:18
msgid "Invoice identifier"
msgstr ""
#: apps/treasury/models.py:32
msgid "BDE"
msgstr ""
#: apps/treasury/models.py:37
msgid "Object"
msgstr ""
#: apps/treasury/models.py:41
msgid "Description"
msgstr ""
#: apps/treasury/models.py:46 templates/note/transaction_form.html:86
msgid "Name"
msgstr ""
#: apps/treasury/models.py:50
msgid "Address"
msgstr ""
#: apps/treasury/models.py:55
msgid "Place"
msgstr ""
#: apps/treasury/models.py:59
msgid "Acquitted"
msgstr ""
#: apps/treasury/models.py:75
msgid "Designation"
msgstr ""
#: apps/treasury/models.py:79
msgid "Quantity"
msgstr ""
#: apps/treasury/models.py:83
msgid "Unit price"
msgstr ""
#: apps/treasury/models.py:120
msgid "Date"
msgstr ""
#: apps/treasury/models.py:126
msgid "Type"
msgstr ""
#: apps/treasury/models.py:131
msgid "Comment"
msgstr ""
#: apps/treasury/models.py:136
msgid "Closed"
msgstr ""
#: apps/treasury/models.py:159
msgid "Remittance #{:d}: {}"
msgstr ""
#: apps/treasury/models.py:178 apps/treasury/tables.py:64
#: apps/treasury/tables.py:72 templates/treasury/invoice_list.html:13
#: templates/treasury/remittance_list.html:13
msgid "Remittance"
msgstr ""
#: apps/treasury/tables.py:16
msgid "Invoice #{:d}"
msgstr ""
#: apps/treasury/tables.py:19 templates/treasury/invoice_list.html:10
#: templates/treasury/remittance_list.html:10
msgid "Invoice"
msgstr ""
#: apps/treasury/tables.py:38
msgid "Transaction count"
msgstr ""
#: apps/treasury/tables.py:43 apps/treasury/tables.py:45
msgid "View"
msgstr ""
#: apps/treasury/tables.py:66
msgid "Add"
msgstr ""
#: apps/treasury/tables.py:74
msgid "Remove"
msgstr ""
#: note_kfet/settings/__init__.py:63
msgid ""
"The Central Authentication Service grants you access to most of our websites "
"by authenticating only once, so you don't need to type your credentials "
"again unless your session expires or you logout."
msgstr ""
#: note_kfet/settings/base.py:153
msgid "German"
msgstr ""
#: note_kfet/settings/base.py:156
#: note_kfet/settings/base.py:154
msgid "English"
msgstr ""
#: note_kfet/settings/base.py:157
#: note_kfet/settings/base.py:155
msgid "French"
msgstr ""
@ -398,6 +650,73 @@ msgstr ""
msgid "The ENS Paris-Saclay BDE note."
msgstr ""
#: templates/base.html:84
msgid "Clubs"
msgstr ""
#: templates/base.html:89
msgid "Activities"
msgstr ""
#: templates/base.html:94
msgid "Buttons"
msgstr ""
#: templates/cas_server/base.html:7
msgid "Central Authentication Service"
msgstr ""
#: templates/cas_server/base.html:43
#, python-format
msgid ""
"A new version of the application is available. This instance runs "
"%(VERSION)s and the last version is %(LAST_VERSION)s. Please consider "
"upgrading."
msgstr ""
#: templates/cas_server/logged.html:4
msgid ""
"<h3>Log In Successful</h3>You have successfully logged into the Central "
"Authentication Service.<br/>For security reasons, please Log Out and Exit "
"your web browser when you are done accessing services that require "
"authentication!"
msgstr ""
#: templates/cas_server/logged.html:8
msgid "Log me out from all my sessions"
msgstr ""
#: templates/cas_server/logged.html:14
msgid "Forget the identity provider"
msgstr ""
#: templates/cas_server/logged.html:18
msgid "Logout"
msgstr ""
#: templates/cas_server/login.html:6
msgid "Please log in"
msgstr ""
#: templates/cas_server/login.html:11
msgid ""
"If you don't have any Note Kfet account, please follow <a href='/accounts/"
"signup'>this link to sign up</a>."
msgstr ""
#: templates/cas_server/login.html:17
msgid "Login"
msgstr ""
#: templates/cas_server/warn.html:9
msgid "Connect to the service"
msgstr ""
#: templates/django_filters/rest_framework/crispy_form.html:4
#: templates/django_filters/rest_framework/form.html:2
msgid "Field filters"
msgstr ""
#: templates/member/club_detail.html:10
msgid "Membership starts on"
msgstr ""
@ -410,10 +729,22 @@ msgstr ""
msgid "Membership duration"
msgstr ""
#: templates/member/club_detail.html:18 templates/member/profile_detail.html:30
#: templates/member/club_detail.html:18 templates/member/profile_detail.html:34
msgid "balance"
msgstr ""
#: templates/member/club_detail.html:51 templates/member/profile_detail.html:75
msgid "Transaction history"
msgstr ""
#: templates/member/club_form.html:6
msgid "Clubs list"
msgstr ""
#: templates/member/club_list.html:8
msgid "New club"
msgstr ""
#: templates/member/manage_auth_tokens.html:16
msgid "Token"
msgstr ""
@ -426,27 +757,35 @@ msgstr ""
msgid "Regenerate token"
msgstr ""
#: templates/member/profile_detail.html:11
#: templates/member/profile_alias.html:10
msgid "Add alias"
msgstr ""
#: templates/member/profile_detail.html:15
msgid "first name"
msgstr ""
#: templates/member/profile_detail.html:14
#: templates/member/profile_detail.html:18
msgid "username"
msgstr ""
#: templates/member/profile_detail.html:17
#: templates/member/profile_detail.html:21
msgid "password"
msgstr ""
#: templates/member/profile_detail.html:20
#: templates/member/profile_detail.html:24
msgid "Change password"
msgstr ""
#: templates/member/profile_detail.html:38
#: templates/member/profile_detail.html:42
msgid "Manage auth token"
msgstr ""
#: templates/member/profile_detail.html:54
#: templates/member/profile_detail.html:49
msgid "View Profile"
msgstr ""
#: templates/member/profile_detail.html:62
msgid "View my memberships"
msgstr ""
@ -454,12 +793,75 @@ msgstr ""
msgid "Save Changes"
msgstr ""
#: templates/member/signup.html:5 templates/member/signup.html:8
#: templates/member/signup.html:14
msgid "Sign Up"
msgid "Sign up"
msgstr ""
#: templates/note/transaction_form.html:35
msgid "Transfer"
#: templates/note/conso_form.html:28 templates/note/transaction_form.html:40
msgid "Select emitters"
msgstr ""
#: templates/note/conso_form.html:45
msgid "Select consumptions"
msgstr ""
#: templates/note/conso_form.html:51
msgid "Consume!"
msgstr ""
#: templates/note/conso_form.html:64
msgid "Most used buttons"
msgstr ""
#: templates/note/conso_form.html:121
msgid "Edit"
msgstr ""
#: templates/note/conso_form.html:126
msgid "Single consumptions"
msgstr ""
#: templates/note/conso_form.html:130
msgid "Double consumptions"
msgstr ""
#: templates/note/conso_form.html:141 templates/note/transaction_form.html:152
msgid "Recent transactions history"
msgstr ""
#: templates/note/transaction_form.html:15
msgid "Gift"
msgstr ""
#: templates/note/transaction_form.html:68
msgid "External payment"
msgstr ""
#: templates/note/transaction_form.html:76
msgid "Transfer type"
msgstr ""
#: templates/note/transaction_form.html:111
#: templates/note/transaction_form.html:169
#: templates/note/transaction_form.html:176
msgid "Select receivers"
msgstr ""
#: templates/note/transaction_form.html:138
msgid "Reason"
msgstr ""
#: templates/note/transaction_form.html:183
msgid "Credit note"
msgstr ""
#: templates/note/transaction_form.html:190
msgid "Debit note"
msgstr ""
#: templates/note/transactiontemplate_form.html:6
msgid "Buttons list"
msgstr ""
#: templates/registration/logged_out.html:8
@ -471,7 +873,7 @@ msgid "Log in again"
msgstr ""
#: templates/registration/login.html:7 templates/registration/login.html:8
#: templates/registration/login.html:22
#: templates/registration/login.html:26
#: templates/registration/password_reset_complete.html:10
msgid "Log in"
msgstr ""
@ -483,7 +885,7 @@ msgid ""
"page. Would you like to login to a different account?"
msgstr ""
#: templates/registration/login.html:23
#: templates/registration/login.html:27
msgid "Forgotten your password or username?"
msgstr ""
@ -539,3 +941,72 @@ msgstr ""
#: templates/registration/password_reset_form.html:11
msgid "Reset my password"
msgstr ""
#: templates/treasury/invoice_form.html:6
msgid "Invoices list"
msgstr ""
#: templates/treasury/invoice_form.html:42
msgid "Add product"
msgstr ""
#: templates/treasury/invoice_form.html:43
msgid "Remove product"
msgstr ""
#: templates/treasury/invoice_list.html:21
msgid "New invoice"
msgstr ""
#: templates/treasury/remittance_form.html:7
msgid "Remittance #"
msgstr ""
#: templates/treasury/remittance_form.html:9
#: templates/treasury/specialtransactionproxy_form.html:7
msgid "Remittances list"
msgstr ""
#: templates/treasury/remittance_form.html:12
msgid "Count"
msgstr ""
#: templates/treasury/remittance_form.html:29
msgid "Linked transactions"
msgstr ""
#: templates/treasury/remittance_form.html:34
msgid "There is no transaction linked with this remittance."
msgstr ""
#: templates/treasury/remittance_list.html:19
msgid "Opened remittances"
msgstr ""
#: templates/treasury/remittance_list.html:24
msgid "There is no opened remittance."
msgstr ""
#: templates/treasury/remittance_list.html:28
msgid "New remittance"
msgstr ""
#: templates/treasury/remittance_list.html:32
msgid "Transfers without remittances"
msgstr ""
#: templates/treasury/remittance_list.html:37
msgid "There is no transaction without any linked remittance."
msgstr ""
#: templates/treasury/remittance_list.html:43
msgid "Transfers with opened remittances"
msgstr ""
#: templates/treasury/remittance_list.html:48
msgid "There is no transaction with an opened linked remittance."
msgstr ""
#: templates/treasury/remittance_list.html:54
msgid "Closed remittances"
msgstr ""

View File

@ -3,7 +3,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2020-02-27 17:39+0100\n"
"POT-Creation-Date: 2020-03-24 15:49+0100\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@ -18,9 +18,10 @@ msgid "activity"
msgstr "activité"
#: apps/activity/models.py:19 apps/activity/models.py:44
#: apps/member/models.py:60 apps/member/models.py:111
#: apps/note/models/notes.py:184 apps/note/models/transactions.py:24
#: apps/note/models/transactions.py:44 templates/member/profile_detail.html:11
#: apps/member/models.py:63 apps/member/models.py:114
#: apps/note/models/notes.py:188 apps/note/models/transactions.py:24
#: apps/note/models/transactions.py:44 apps/note/models/transactions.py:198
#: templates/member/profile_detail.html:15
msgid "name"
msgstr "nom"
@ -41,10 +42,11 @@ msgid "activity types"
msgstr "types d'activité"
#: apps/activity/models.py:48 apps/note/models/transactions.py:69
#: apps/permission/models.py:90
msgid "description"
msgstr "description"
#: apps/activity/models.py:54 apps/note/models/notes.py:160
#: apps/activity/models.py:54 apps/note/models/notes.py:164
#: apps/note/models/transactions.py:62
msgid "type"
msgstr "type"
@ -77,65 +79,121 @@ msgstr "invité"
msgid "guests"
msgstr "invités"
#: apps/member/apps.py:10
#: apps/api/apps.py:10
msgid "API"
msgstr ""
#: apps/logs/apps.py:11
msgid "Logs"
msgstr ""
#: apps/logs/models.py:21 apps/note/models/notes.py:117
msgid "user"
msgstr "utilisateur"
#: apps/logs/models.py:27
msgid "IP Address"
msgstr "Adresse IP"
#: apps/logs/models.py:35
msgid "model"
msgstr "Modèle"
#: apps/logs/models.py:42
msgid "identifier"
msgstr "Identifiant"
#: apps/logs/models.py:47
msgid "previous data"
msgstr "Données précédentes"
#: apps/logs/models.py:52
msgid "new data"
msgstr "Nouvelles données"
#: apps/logs/models.py:60
msgid "create"
msgstr "Créer"
#: apps/logs/models.py:61
msgid "edit"
msgstr "Modifier"
#: apps/logs/models.py:62
msgid "delete"
msgstr "Supprimer"
#: apps/logs/models.py:65
msgid "action"
msgstr "Action"
#: apps/logs/models.py:73
msgid "timestamp"
msgstr "Date"
#: apps/logs/models.py:77
msgid "Logs cannot be destroyed."
msgstr "Les logs ne peuvent pas être détruits."
#: apps/member/apps.py:14
msgid "member"
msgstr "adhérent"
#: apps/member/models.py:23
#: apps/member/models.py:25
msgid "phone number"
msgstr "numéro de téléphone"
#: apps/member/models.py:29 templates/member/profile_detail.html:24
#: apps/member/models.py:31 templates/member/profile_detail.html:28
msgid "section"
msgstr "section"
#: apps/member/models.py:30
#: apps/member/models.py:32
msgid "e.g. \"1A0\", \"9A♥\", \"SAPHIRE\""
msgstr "e.g. \"1A0\", \"9A♥\", \"SAPHIRE\""
#: apps/member/models.py:36 templates/member/profile_detail.html:27
#: apps/member/models.py:38 templates/member/profile_detail.html:31
msgid "address"
msgstr "adresse"
#: apps/member/models.py:42
#: apps/member/models.py:44
msgid "paid"
msgstr "payé"
#: apps/member/models.py:47 apps/member/models.py:48
#: apps/member/models.py:49 apps/member/models.py:50
msgid "user profile"
msgstr "profil utilisateur"
#: apps/member/models.py:65
#: apps/member/models.py:68
msgid "email"
msgstr "courriel"
#: apps/member/models.py:70
#: apps/member/models.py:73
msgid "membership fee"
msgstr "cotisation pour adhérer"
#: apps/member/models.py:74
#: apps/member/models.py:77
msgid "membership duration"
msgstr "durée de l'adhésion"
#: apps/member/models.py:75
#: apps/member/models.py:78
msgid "The longest time a membership can last (NULL = infinite)."
msgstr "La durée maximale d'une adhésion (NULL = infinie)."
#: apps/member/models.py:80
#: apps/member/models.py:83
msgid "membership start"
msgstr "début de l'adhésion"
#: apps/member/models.py:81
#: apps/member/models.py:84
msgid "How long after January 1st the members can renew their membership."
msgstr ""
"Combien de temps après le 1er Janvier les adhérents peuvent renouveler leur "
"adhésion."
#: apps/member/models.py:86
#: apps/member/models.py:89
msgid "membership end"
msgstr "fin de l'adhésion"
#: apps/member/models.py:87
#: apps/member/models.py:90
msgid ""
"How long the membership can last after January 1st of the next year after "
"members can renew their membership."
@ -143,166 +201,174 @@ msgstr ""
"Combien de temps l'adhésion peut durer après le 1er Janvier de l'année "
"suivante avant que les adhérents peuvent renouveler leur adhésion."
#: apps/member/models.py:93 apps/note/models/notes.py:135
#: apps/member/models.py:96 apps/note/models/notes.py:139
msgid "club"
msgstr "club"
#: apps/member/models.py:94
#: apps/member/models.py:97
msgid "clubs"
msgstr "clubs"
#: apps/member/models.py:117
#: apps/member/models.py:120 apps/permission/models.py:275
msgid "role"
msgstr "rôle"
#: apps/member/models.py:118
#: apps/member/models.py:121
msgid "roles"
msgstr "rôles"
#: apps/member/models.py:142
#: apps/member/models.py:145
msgid "membership starts on"
msgstr "l'adhésion commence le"
#: apps/member/models.py:145
#: apps/member/models.py:148
msgid "membership ends on"
msgstr "l'adhésion finie le"
#: apps/member/models.py:149
#: apps/member/models.py:152
msgid "fee"
msgstr "cotisation"
#: apps/member/models.py:153
#: apps/member/models.py:162
msgid "membership"
msgstr "adhésion"
#: apps/member/models.py:154
#: apps/member/models.py:163
msgid "memberships"
msgstr "adhésions"
#: apps/member/views.py:63 templates/member/profile_detail.html:42
#: apps/member/views.py:80 templates/member/profile_detail.html:46
msgid "Update Profile"
msgstr "Modifier le profil"
#: apps/member/views.py:79
#: apps/member/views.py:93
msgid "An alias with a similar name already exists."
msgstr "Un alias avec un nom similaire existe déjà."
#: apps/member/views.py:130
#: apps/member/views.py:146
#, python-format
msgid "Account #%(id)s: %(username)s"
msgstr "Compte n°%(id)s : %(username)s"
#: apps/note/admin.py:120 apps/note/models/transactions.py:93
#: apps/member/views.py:216
msgid "Alias successfully deleted"
msgstr "L'alias a bien été supprimé"
#: apps/note/admin.py:120 apps/note/models/transactions.py:94
msgid "source"
msgstr "source"
#: apps/note/admin.py:128 apps/note/admin.py:156
#: apps/note/models/transactions.py:53 apps/note/models/transactions.py:99
#: apps/note/models/transactions.py:53 apps/note/models/transactions.py:100
msgid "destination"
msgstr "destination"
#: apps/note/apps.py:14 apps/note/models/notes.py:54
#: apps/note/apps.py:14 apps/note/models/notes.py:58
msgid "note"
msgstr "note"
#: apps/note/forms.py:49
msgid "Source and destination must be different."
msgstr "La source et la destination doivent être différentes."
#: apps/note/forms.py:20
msgid "New Alias"
msgstr "Nouvel alias"
#: apps/note/models/notes.py:26
#: apps/note/forms.py:25
msgid "select an image"
msgstr "Choisissez une image"
#: apps/note/forms.py:26
msgid "Maximal size: 2MB"
msgstr "Taille maximale : 2 Mo"
#: apps/note/models/notes.py:27
msgid "account balance"
msgstr "solde du compte"
#: apps/note/models/notes.py:27
#: apps/note/models/notes.py:28
msgid "in centimes, money credited for this instance"
msgstr "en centimes, argent crédité pour cette instance"
#: apps/note/models/notes.py:31
#: apps/note/models/notes.py:32
msgid "last negative date"
msgstr "dernier date de négatif"
#: apps/note/models/notes.py:32
#: apps/note/models/notes.py:33
msgid "last time the balance was negative"
msgstr "dernier instant où la note était en négatif"
#: apps/note/models/notes.py:37
#: apps/note/models/notes.py:38
msgid "active"
msgstr "actif"
#: apps/note/models/notes.py:40
#: apps/note/models/notes.py:41
msgid ""
"Designates whether this note should be treated as active. Unselect this "
"instead of deleting notes."
msgstr ""
"Indique si la note est active. Désactiver cela plutôt que supprimer la note."
#: apps/note/models/notes.py:44
#: apps/note/models/notes.py:45
msgid "display image"
msgstr "image affichée"
#: apps/note/models/notes.py:49 apps/note/models/transactions.py:102
#: apps/note/models/notes.py:53 apps/note/models/transactions.py:103
msgid "created at"
msgstr "créée le"
#: apps/note/models/notes.py:55
#: apps/note/models/notes.py:59
msgid "notes"
msgstr "notes"
#: apps/note/models/notes.py:63
#: apps/note/models/notes.py:67
msgid "Note"
msgstr "Note"
#: apps/note/models/notes.py:73 apps/note/models/notes.py:97
#: apps/note/models/notes.py:77 apps/note/models/notes.py:101
msgid "This alias is already taken."
msgstr "Cet alias est déjà pris."
#: apps/note/models/notes.py:113
msgid "user"
msgstr "utilisateur"
#: apps/note/models/notes.py:117
#: apps/note/models/notes.py:121
msgid "one's note"
msgstr "note d'un utilisateur"
#: apps/note/models/notes.py:118
#: apps/note/models/notes.py:122
msgid "users note"
msgstr "notes des utilisateurs"
#: apps/note/models/notes.py:124
#: apps/note/models/notes.py:128
#, python-format
msgid "%(user)s's note"
msgstr "Note de %(user)s"
#: apps/note/models/notes.py:139
#: apps/note/models/notes.py:143
msgid "club note"
msgstr "note d'un club"
#: apps/note/models/notes.py:140
#: apps/note/models/notes.py:144
msgid "clubs notes"
msgstr "notes des clubs"
#: apps/note/models/notes.py:146
#: apps/note/models/notes.py:150
#, python-format
msgid "Note of %(club)s club"
msgstr "Note du club %(club)s"
#: apps/note/models/notes.py:166
#: apps/note/models/notes.py:170
msgid "special note"
msgstr "note spéciale"
#: apps/note/models/notes.py:167
#: apps/note/models/notes.py:171
msgid "special notes"
msgstr "notes spéciales"
#: apps/note/models/notes.py:190
#: apps/note/models/notes.py:194
msgid "Invalid alias"
msgstr "Alias invalide"
#: apps/note/models/notes.py:206
#: apps/note/models/notes.py:210
msgid "alias"
msgstr "alias"
#: apps/note/models/notes.py:207 templates/member/profile_detail.html:33
#: apps/note/models/notes.py:211 templates/member/profile_detail.html:37
msgid "aliases"
msgstr "alias"
@ -311,10 +377,10 @@ msgid "Alias is too long."
msgstr "L'alias est trop long."
#: apps/note/models/notes.py:238
msgid "An alias with a similar name already exists:"
msgstr "Un alias avec un nom similaire existe déjà."
msgid "An alias with a similar name already exists: {} "
msgstr "Un alias avec un nom similaire existe déjà : {}"
#: apps/note/models/notes.py:246
#: apps/note/models/notes.py:247
msgid "You can't delete your main alias."
msgstr "Vous ne pouvez pas supprimer votre alias principal."
@ -327,11 +393,10 @@ msgid "transaction categories"
msgstr "catégories de transaction"
#: apps/note/models/transactions.py:47
#, fuzzy
msgid "A template with this name already exist"
msgstr "Un modèle de transaction avec un nom similaire existe déjà."
#: apps/note/models/transactions.py:56 apps/note/models/transactions.py:109
#: apps/note/models/transactions.py:56 apps/note/models/transactions.py:111
msgid "amount"
msgstr "montant"
@ -339,59 +404,245 @@ msgstr "montant"
msgid "in centimes"
msgstr "en centimes"
#: apps/note/models/transactions.py:74
#: apps/note/models/transactions.py:75
msgid "transaction template"
msgstr "modèle de transaction"
#: apps/note/models/transactions.py:75
#: apps/note/models/transactions.py:76
msgid "transaction templates"
msgstr "modèles de transaction"
#: apps/note/models/transactions.py:106
#: apps/note/models/transactions.py:107
msgid "quantity"
msgstr "quantité"
#: apps/note/models/transactions.py:111
#: apps/note/models/transactions.py:115
msgid "reason"
msgstr "raison"
#: apps/note/models/transactions.py:115
#: apps/note/models/transactions.py:119
msgid "valid"
msgstr "valide"
#: apps/note/models/transactions.py:120
#: apps/note/models/transactions.py:124
msgid "transaction"
msgstr "transaction"
#: apps/note/models/transactions.py:121
#: apps/note/models/transactions.py:125
msgid "transactions"
msgstr "transactions"
#: apps/note/models/transactions.py:184
#: apps/note/models/transactions.py:168 templates/base.html:98
#: templates/note/transaction_form.html:19
#: templates/note/transaction_form.html:145
msgid "Transfer"
msgstr "Virement"
#: apps/note/models/transactions.py:188
msgid "Template"
msgstr "Bouton"
#: apps/note/models/transactions.py:203
msgid "first_name"
msgstr "prénom"
#: apps/note/models/transactions.py:208
msgid "bank"
msgstr "banque"
#: apps/note/models/transactions.py:214 templates/note/transaction_form.html:24
msgid "Credit"
msgstr "Crédit"
#: apps/note/models/transactions.py:214 templates/note/transaction_form.html:28
msgid "Debit"
msgstr "Débit"
#: apps/note/models/transactions.py:230 apps/note/models/transactions.py:235
msgid "membership transaction"
msgstr "transaction d'adhésion"
#: apps/note/models/transactions.py:185
#: apps/note/models/transactions.py:231
msgid "membership transactions"
msgstr "transactions d'adhésion"
#: apps/note/views.py:29
msgid "Transfer money from your account to one or others"
msgstr "Transfert d'argent de ton compte vers un ou plusieurs autres"
#: apps/note/views.py:39
msgid "Transfer money"
msgstr "Transférer de l'argent"
#: apps/note/views.py:138
msgid "Consommations"
msgstr "transactions"
#: apps/note/views.py:145 templates/base.html:79
msgid "Consumptions"
msgstr "Consommations"
#: note_kfet/settings/base.py:155
#: apps/permission/models.py:69 apps/permission/models.py:262
#, python-brace-format
msgid "Can {type} {model}.{field} in {query}"
msgstr ""
#: apps/permission/models.py:71 apps/permission/models.py:264
#, python-brace-format
msgid "Can {type} {model} in {query}"
msgstr ""
#: apps/permission/models.py:84
msgid "rank"
msgstr "Rang"
#: apps/permission/models.py:147
msgid "Specifying field applies only to view and change permission types."
msgstr ""
#: apps/treasury/apps.py:11 templates/base.html:102
msgid "Treasury"
msgstr "Trésorerie"
#: apps/treasury/forms.py:56 apps/treasury/forms.py:95
#: templates/django_filters/rest_framework/form.html:5
#: templates/member/club_form.html:10 templates/treasury/invoice_form.html:47
msgid "Submit"
msgstr "Envoyer"
#: apps/treasury/forms.py:58
msgid "Close"
msgstr "Fermer"
#: apps/treasury/forms.py:65
msgid "Remittance is already closed."
msgstr "La remise est déjà fermée."
#: apps/treasury/forms.py:70
msgid "You can't change the type of the remittance."
msgstr "Vous ne pouvez pas changer le type de la remise."
#: apps/treasury/forms.py:84
msgid "Last name"
msgstr "Nom de famille"
#: apps/treasury/forms.py:86 templates/note/transaction_form.html:92
msgid "First name"
msgstr "Prénom"
#: apps/treasury/forms.py:88 templates/note/transaction_form.html:98
msgid "Bank"
msgstr "Banque"
#: apps/treasury/forms.py:90 apps/treasury/tables.py:40
#: templates/note/transaction_form.html:128
#: templates/treasury/remittance_form.html:18
msgid "Amount"
msgstr "Montant"
#: apps/treasury/models.py:18
msgid "Invoice identifier"
msgstr "Numéro de facture"
#: apps/treasury/models.py:32
msgid "BDE"
msgstr "BDE"
#: apps/treasury/models.py:37
msgid "Object"
msgstr "Objet"
#: apps/treasury/models.py:41
msgid "Description"
msgstr "Description"
#: apps/treasury/models.py:46 templates/note/transaction_form.html:86
msgid "Name"
msgstr "Nom"
#: apps/treasury/models.py:50
msgid "Address"
msgstr "Adresse"
#: apps/treasury/models.py:55
msgid "Place"
msgstr "Lieu"
#: apps/treasury/models.py:59
msgid "Acquitted"
msgstr "Acquittée"
#: apps/treasury/models.py:75
msgid "Designation"
msgstr "Désignation"
#: apps/treasury/models.py:79
msgid "Quantity"
msgstr "Quantité"
#: apps/treasury/models.py:83
msgid "Unit price"
msgstr "Prix unitaire"
#: apps/treasury/models.py:120
msgid "Date"
msgstr "Date"
#: apps/treasury/models.py:126
msgid "Type"
msgstr "Type"
#: apps/treasury/models.py:131
msgid "Comment"
msgstr "Commentaire"
#: apps/treasury/models.py:136
msgid "Closed"
msgstr "Fermée"
#: apps/treasury/models.py:159
msgid "Remittance #{:d}: {}"
msgstr "Remise n°{:d} : {}"
#: apps/treasury/models.py:178 apps/treasury/tables.py:64
#: apps/treasury/tables.py:72 templates/treasury/invoice_list.html:13
#: templates/treasury/remittance_list.html:13
msgid "Remittance"
msgstr "Remise"
#: apps/treasury/tables.py:16
msgid "Invoice #{:d}"
msgstr "Facture n°{:d}"
#: apps/treasury/tables.py:19 templates/treasury/invoice_list.html:10
#: templates/treasury/remittance_list.html:10
msgid "Invoice"
msgstr "Facture"
#: apps/treasury/tables.py:38
msgid "Transaction count"
msgstr "Nombre de transactions"
#: apps/treasury/tables.py:43 apps/treasury/tables.py:45
msgid "View"
msgstr "Voir"
#: apps/treasury/tables.py:66
msgid "Add"
msgstr "Ajouter"
#: apps/treasury/tables.py:74
msgid "Remove"
msgstr "supprimer"
#: note_kfet/settings/__init__.py:63
msgid ""
"The Central Authentication Service grants you access to most of our websites "
"by authenticating only once, so you don't need to type your credentials "
"again unless your session expires or you logout."
msgstr ""
#: note_kfet/settings/base.py:153
msgid "German"
msgstr ""
#: note_kfet/settings/base.py:156
#: note_kfet/settings/base.py:154
msgid "English"
msgstr ""
#: note_kfet/settings/base.py:157
#: note_kfet/settings/base.py:155
msgid "French"
msgstr ""
@ -399,6 +650,75 @@ msgstr ""
msgid "The ENS Paris-Saclay BDE note."
msgstr "La note du BDE de l'ENS Paris-Saclay."
#: templates/base.html:84
msgid "Clubs"
msgstr "Clubs"
#: templates/base.html:89
msgid "Activities"
msgstr "Activités"
#: templates/base.html:94
msgid "Buttons"
msgstr "Boutons"
#: templates/cas_server/base.html:7
msgid "Central Authentication Service"
msgstr ""
#: templates/cas_server/base.html:43
#, python-format
msgid ""
"A new version of the application is available. This instance runs "
"%(VERSION)s and the last version is %(LAST_VERSION)s. Please consider "
"upgrading."
msgstr ""
#: templates/cas_server/logged.html:4
msgid ""
"<h3>Log In Successful</h3>You have successfully logged into the Central "
"Authentication Service.<br/>For security reasons, please Log Out and Exit "
"your web browser when you are done accessing services that require "
"authentication!"
msgstr ""
#: templates/cas_server/logged.html:8
msgid "Log me out from all my sessions"
msgstr ""
#: templates/cas_server/logged.html:14
msgid "Forget the identity provider"
msgstr ""
#: templates/cas_server/logged.html:18
msgid "Logout"
msgstr ""
#: templates/cas_server/login.html:6
msgid "Please log in"
msgstr ""
#: templates/cas_server/login.html:11
msgid ""
"If you don't have any Note Kfet account, please follow <a href='/accounts/"
"signup'>this link to sign up</a>."
msgstr ""
"Si vous n'avez pas de compte Note Kfet, veuillez suivre <a href='/accounts/"
"signup'>ce lien pour vous inscrire</a>."
#: templates/cas_server/login.html:17
msgid "Login"
msgstr ""
#: templates/cas_server/warn.html:9
msgid "Connect to the service"
msgstr ""
#: templates/django_filters/rest_framework/crispy_form.html:4
#: templates/django_filters/rest_framework/form.html:2
msgid "Field filters"
msgstr ""
#: templates/member/club_detail.html:10
msgid "Membership starts on"
msgstr "L'adhésion commence le"
@ -411,10 +731,22 @@ msgstr "L'adhésion finie le"
msgid "Membership duration"
msgstr "Durée de l'adhésion"
#: templates/member/club_detail.html:18 templates/member/profile_detail.html:30
#: templates/member/club_detail.html:18 templates/member/profile_detail.html:34
msgid "balance"
msgstr "solde du compte"
#: templates/member/club_detail.html:51 templates/member/profile_detail.html:75
msgid "Transaction history"
msgstr "Historique des transactions"
#: templates/member/club_form.html:6
msgid "Clubs list"
msgstr "Liste des clubs"
#: templates/member/club_list.html:8
msgid "New club"
msgstr "Nouveau club"
#: templates/member/manage_auth_tokens.html:16
msgid "Token"
msgstr "Jeton"
@ -427,33 +759,35 @@ msgstr "Créé le"
msgid "Regenerate token"
msgstr "Regénérer le jeton"
#: templates/member/profile_detail.html:11
#: templates/member/profile_alias.html:10
msgid "Add alias"
msgstr "Ajouter un alias"
#: templates/member/profile_detail.html:15
msgid "first name"
msgstr ""
msgstr "prénom"
#: templates/member/profile_detail.html:14
#: templates/member/profile_detail.html:18
msgid "username"
msgstr ""
msgstr "pseudo"
#: templates/member/profile_detail.html:17
#, fuzzy
#| msgid "Change password"
#: templates/member/profile_detail.html:21
msgid "password"
msgstr ""
msgstr "mot de passe"
#: templates/member/profile_detail.html:20
#: templates/member/profile_detail.html:24
msgid "Change password"
msgstr "Changer le mot de passe"
#: templates/member/profile_detail.html:38
#: templates/member/profile_detail.html:42
msgid "Manage auth token"
msgstr "Gérer les jetons d'authentification"
#: templates/member/profile_detail.html:51
msgid "Transaction history"
msgstr "Historique des transactions"
#: templates/member/profile_detail.html:49
msgid "View Profile"
msgstr "Voir le profil"
#: templates/member/profile_detail.html:54
#: templates/member/profile_detail.html:62
msgid "View my memberships"
msgstr "Voir mes adhésions"
@ -461,13 +795,76 @@ msgstr "Voir mes adhésions"
msgid "Save Changes"
msgstr "Sauvegarder les changements"
#: templates/member/signup.html:5 templates/member/signup.html:8
#: templates/member/signup.html:14
msgid "Sign Up"
msgstr ""
msgid "Sign up"
msgstr "Inscription"
#: templates/note/transaction_form.html:35
msgid "Transfer"
msgstr "Virement"
#: templates/note/conso_form.html:28 templates/note/transaction_form.html:40
msgid "Select emitters"
msgstr "Sélection des émetteurs"
#: templates/note/conso_form.html:45
msgid "Select consumptions"
msgstr "Sélection des consommations"
#: templates/note/conso_form.html:51
msgid "Consume!"
msgstr "Consommer !"
#: templates/note/conso_form.html:64
msgid "Most used buttons"
msgstr "Boutons les plus utilisés"
#: templates/note/conso_form.html:121
msgid "Edit"
msgstr "Éditer"
#: templates/note/conso_form.html:126
msgid "Single consumptions"
msgstr "Consommations simples"
#: templates/note/conso_form.html:130
msgid "Double consumptions"
msgstr "Consommations doubles"
#: templates/note/conso_form.html:141 templates/note/transaction_form.html:152
msgid "Recent transactions history"
msgstr "Historique des transactions récentes"
#: templates/note/transaction_form.html:15
msgid "Gift"
msgstr "Don"
#: templates/note/transaction_form.html:68
msgid "External payment"
msgstr "Paiement externe"
#: templates/note/transaction_form.html:76
msgid "Transfer type"
msgstr "Type de transfert"
#: templates/note/transaction_form.html:111
#: templates/note/transaction_form.html:169
#: templates/note/transaction_form.html:176
msgid "Select receivers"
msgstr "Sélection des destinataires"
#: templates/note/transaction_form.html:138
msgid "Reason"
msgstr "Raison"
#: templates/note/transaction_form.html:183
msgid "Credit note"
msgstr "Note à recharger"
#: templates/note/transaction_form.html:190
msgid "Debit note"
msgstr "Note à débiter"
#: templates/note/transactiontemplate_form.html:6
msgid "Buttons list"
msgstr "Liste des boutons"
#: templates/registration/logged_out.html:8
msgid "Thanks for spending some quality time with the Web site today."
@ -478,7 +875,7 @@ msgid "Log in again"
msgstr ""
#: templates/registration/login.html:7 templates/registration/login.html:8
#: templates/registration/login.html:22
#: templates/registration/login.html:26
#: templates/registration/password_reset_complete.html:10
msgid "Log in"
msgstr ""
@ -490,7 +887,7 @@ msgid ""
"page. Would you like to login to a different account?"
msgstr ""
#: templates/registration/login.html:23
#: templates/registration/login.html:27
msgid "Forgotten your password or username?"
msgstr ""
@ -546,3 +943,72 @@ msgstr ""
#: templates/registration/password_reset_form.html:11
msgid "Reset my password"
msgstr ""
#: templates/treasury/invoice_form.html:6
msgid "Invoices list"
msgstr "Liste des factures"
#: templates/treasury/invoice_form.html:42
msgid "Add product"
msgstr "Ajouter produit"
#: templates/treasury/invoice_form.html:43
msgid "Remove product"
msgstr "Retirer produit"
#: templates/treasury/invoice_list.html:21
msgid "New invoice"
msgstr "Nouvelle facture"
#: templates/treasury/remittance_form.html:7
msgid "Remittance #"
msgstr "Remise n°"
#: templates/treasury/remittance_form.html:9
#: templates/treasury/specialtransactionproxy_form.html:7
msgid "Remittances list"
msgstr "Liste des remises"
#: templates/treasury/remittance_form.html:12
msgid "Count"
msgstr "Nombre"
#: templates/treasury/remittance_form.html:29
msgid "Linked transactions"
msgstr "Transactions liées"
#: templates/treasury/remittance_form.html:34
msgid "There is no transaction linked with this remittance."
msgstr "Il n'y a pas de transaction liée à cette remise."
#: templates/treasury/remittance_list.html:19
msgid "Opened remittances"
msgstr "Remises ouvertes"
#: templates/treasury/remittance_list.html:24
msgid "There is no opened remittance."
msgstr "Il n'y a pas de remise ouverte."
#: templates/treasury/remittance_list.html:28
msgid "New remittance"
msgstr "Nouvelle remise"
#: templates/treasury/remittance_list.html:32
msgid "Transfers without remittances"
msgstr "Transactions sans remise associée"
#: templates/treasury/remittance_list.html:37
msgid "There is no transaction without any linked remittance."
msgstr "Il n'y a pas de transactions sans remise associée."
#: templates/treasury/remittance_list.html:43
msgid "Transfers with opened remittances"
msgstr "Transactions associées à une remise ouverte"
#: templates/treasury/remittance_list.html:48
msgid "There is no transaction with an opened linked remittance."
msgstr "Il n'y a pas de transaction associée à une remise ouverte."
#: templates/treasury/remittance_list.html:54
msgid "Closed remittances"
msgstr "Remises fermées"

BIN
media/pic/default.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

View File

@ -9,7 +9,7 @@ server {
# the port your site will be served on
listen 80;
# the domain name it will serve for
server_name note.comby.xyz; # substitute your machine's IP address or FQDN
server_name note.example.org; # substitute your machine's IP address or FQDN
charset utf-8;
# max upload size

View File

@ -0,0 +1,11 @@
[
{
"model": "cas_server.servicepattern",
"pk": 1,
"fields": {
"pos": 1,
"pattern": ".*",
"name": "REPLACEME"
}
}
]

View File

@ -1,9 +1,65 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from django.http import HttpResponseRedirect
from django.conf import settings
from django.contrib.auth.models import AnonymousUser, User
from urllib.parse import urlencode, parse_qs, urlsplit, urlunsplit
from threading import local
from django.contrib.sessions.backends.db import SessionStore
USER_ATTR_NAME = getattr(settings, 'LOCAL_USER_ATTR_NAME', '_current_user')
SESSION_ATTR_NAME = getattr(settings, 'LOCAL_SESSION_ATTR_NAME', '_current_session')
IP_ATTR_NAME = getattr(settings, 'LOCAL_IP_ATTR_NAME', '_current_ip')
_thread_locals = local()
def _set_current_user_and_ip(user=None, session=None, ip=None):
setattr(_thread_locals, USER_ATTR_NAME, user)
setattr(_thread_locals, SESSION_ATTR_NAME, session)
setattr(_thread_locals, IP_ATTR_NAME, ip)
def get_current_user() -> User:
return getattr(_thread_locals, USER_ATTR_NAME, None)
def get_current_session() -> SessionStore:
return getattr(_thread_locals, SESSION_ATTR_NAME, None)
def get_current_ip() -> str:
return getattr(_thread_locals, IP_ATTR_NAME, None)
def get_current_authenticated_user():
current_user = get_current_user()
if isinstance(current_user, AnonymousUser):
return None
return current_user
class SessionMiddleware(object):
"""
This middleware get the current user with his or her IP address on each request.
"""
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
user = request.user
if 'HTTP_X_FORWARDED_FOR' in request.META:
ip = request.META.get('HTTP_X_FORWARDED_FOR')
else:
ip = request.META.get('REMOTE_ADDR')
_set_current_user_and_ip(user, request.session, ip)
response = self.get_response(request)
_set_current_user_and_ip(None, None, None)
return response
class TurbolinksMiddleware(object):
@ -35,4 +91,3 @@ class TurbolinksMiddleware(object):
location = request.session.pop('_turbolinks_redirect_to')
response['Turbolinks-Location'] = location
return response

View File

@ -1,8 +1,12 @@
import os
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from django.utils.translation import gettext_lazy as _
import re
from .base import *
def read_env():
"""Pulled from Honcho code with minor updates, reads local default
environment variables from a .env file located in the project root
@ -25,22 +29,55 @@ def read_env():
val = re.sub(r'\\(.)', r'\1', m3.group(1))
os.environ.setdefault(key, val)
read_env()
app_stage = os.environ.get('DJANGO_APP_STAGE', 'dev')
if app_stage == 'prod':
from .production import *
DATABASES["default"]["PASSWORD"] = os.environ.get('DJANGO_DB_PASSWORD','CHANGE_ME_IN_ENV_SETTINGS')
SECRET_KEY = os.environ.get('DJANGO_SECRET_KEY','CHANGE_ME_IN_ENV_SETTINGS')
ALLOWED_HOSTS.append(os.environ.get('ALLOWED_HOSTS','localhost'))
else:
from .development import *
try:
#in secrets.py defines everything you want
from .secrets import *
INSTALLED_APPS += OPTIONAL_APPS
except ImportError:
pass
# env variables set at the of in /env/bin/activate
# don't forget to unset in deactivate !
if "cas" in INSTALLED_APPS:
MIDDLEWARE += ['cas.middleware.CASMiddleware']
# CAS Settings
CAS_SERVER_URL = "https://" + os.getenv("NOTE_URL", "note.example.com") + "/cas/"
CAS_AUTO_CREATE_USER = False
CAS_LOGO_URL = "/static/img/Saperlistpopette.png"
CAS_FAVICON_URL = "/static/favicon/favicon-32x32.png"
CAS_SHOW_SERVICE_MESSAGES = True
CAS_SHOW_POWERED = False
CAS_REDIRECT_TO_LOGIN_AFTER_LOGOUT = False
CAS_PROVIDE_URL_TO_LOGOUT = True
CAS_INFO_MESSAGES = {
"cas_explained": {
"message": _(
u"The Central Authentication Service grants you access to most of our websites by "
u"authenticating only once, so you don't need to type your credentials again unless "
u"your session expires or you logout."
),
"discardable": True,
"type": "info", # one of info, success, info, warning, danger
},
}
CAS_INFO_MESSAGES_ORDER = [
'cas_explained',
]
AUTHENTICATION_BACKENDS += ('cas.backends.CASBackend',)
if "logs" in INSTALLED_APPS:
MIDDLEWARE += ('note_kfet.middlewares.SessionMiddleware',)
if "debug_toolbar" in INSTALLED_APPS:
MIDDLEWARE.insert(1, "debug_toolbar.middleware.DebugToolbarMiddleware")
INTERNAL_IPS = ['127.0.0.1']

View File

@ -37,7 +37,6 @@ INSTALLED_APPS = [
# External apps
'polymorphic',
'reversion',
'crispy_forms',
'django_tables2',
# Django contrib
@ -60,7 +59,10 @@ INSTALLED_APPS = [
'activity',
'member',
'note',
'treasury',
'permission',
'api',
'logs',
]
LOGIN_REDIRECT_URL = '/note/transfer/'
@ -92,6 +94,7 @@ TEMPLATES = [
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
'django.template.context_processors.request',
# 'django.template.context_processors.media',
],
},
},
@ -123,29 +126,23 @@ PASSWORD_HASHERS = [
'member.hashers.CustomNK15Hasher',
]
# Django Guardian object permissions
AUTHENTICATION_BACKENDS = (
'django.contrib.auth.backends.ModelBackend', # this is default
'guardian.backends.ObjectPermissionBackend',
'permission.backends.PermissionBackend', # Custom role-based permission system
)
REST_FRAMEWORK = {
# Use Django's standard `django.contrib.auth` permissions,
# or allow read-only access for unauthenticated users.
'DEFAULT_PERMISSION_CLASSES': [
# TODO Maybe replace it with our custom permissions system
'rest_framework.permissions.DjangoModelPermissionsOrAnonReadOnly'
# Control API access with our role-based permission system
'permission.permissions.StrongDjangoObjectPermissions',
],
'DEFAULT_AUTHENTICATION_CLASSES': [
'rest_framework.authentication.SessionAuthentication',
'rest_framework.authentication.TokenAuthentication',
]
],
'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination',
'PAGE_SIZE': 20,
}
ANONYMOUS_USER_NAME = None # Disable guardian anonymous user
GUARDIAN_GET_CONTENT_TYPE = 'polymorphic.contrib.guardian.get_polymorphic_base_content_type'
# Internationalization
# https://docs.djangoproject.com/en/2.2/topics/i18n/
@ -176,10 +173,10 @@ FIXTURE_DIRS = [os.path.join(BASE_DIR, "note_kfet/fixtures")]
# Don't put anything in this directory yourself; store your static files
# in apps' "static/" subdirectories and in STATICFILES_DIRS.
# Example: "/var/www/example.com/static/"
STATIC_ROOT = os.path.realpath(__file__)
STATICFILES_DIRS = [
os.path.join(BASE_DIR, 'static')]
STATIC_ROOT = os.path.join(BASE_DIR, "static/")
# STATICFILES_DIRS = [
# os.path.join(BASE_DIR, 'static')]
STATICFILES_DIRS = []
CRISPY_TEMPLATE_PACK = 'bootstrap4'
DJANGO_TABLES2_TEMPLATE = 'django_tables2/bootstrap4.html'
# URL prefix for static files.
@ -188,3 +185,9 @@ STATIC_URL = '/static/'
ALIAS_VALIDATOR_REGEX = r''
MEDIA_ROOT = os.path.join(BASE_DIR, "media")
MEDIA_URL = '/media/'
# Profile Picture Settings
PIC_WIDTH = 200
PIC_RATIO = 1

View File

@ -11,17 +11,30 @@
# - and more ...
import os
# Database
# https://docs.djangoproject.com/en/2.2/ref/settings/#databases
from . import *
import os
DATABASES = {
if os.getenv("DJANGO_DEV_STORE_METHOD", "sqllite") == "postgresql":
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.postgresql_psycopg2',
'NAME': os.environ.get('DJANGO_DB_NAME', 'note_db'),
'USER': os.environ.get('DJANGO_DB_USER', 'note'),
'PASSWORD': os.environ.get('DJANGO_DB_PASSWORD', 'CHANGE_ME_IN_ENV_SETTINGS'),
'HOST': os.environ.get('DJANGO_DB_HOST', 'localhost'),
'PORT': os.environ.get('DJANGO_DB_PORT', ''), # Use default port
}
}
else:
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': os.path.join(BASE_DIR, 'db.sqlite3'),
}
}
}
# Break it, fix it!
DEBUG = True
@ -38,7 +51,7 @@ EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'
# EMAIL_HOST_USER = 'change_me'
# EMAIL_HOST_PASSWORD = 'change_me'
SERVER_EMAIL = 'no-reply@example.org'
SERVER_EMAIL = 'no-reply@' + os.getenv("DOMAIN", "example.com")
# Security settings
SECURE_CONTENT_TYPE_NOSNIFF = False
@ -48,3 +61,11 @@ CSRF_COOKIE_SECURE = False
CSRF_COOKIE_HTTPONLY = False
X_FRAME_OPTIONS = 'DENY'
SESSION_COOKIE_AGE = 60 * 60 * 3
# CAS Client settings
# Can be modified in secrets.py
CAS_SERVER_URL = "http://localhost:8000/cas/"
STATIC_ROOT = '' # not needed in development settings
STATICFILES_DIRS = [
os.path.join(BASE_DIR, 'static')]

View File

@ -1,6 +1,8 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
import os
########################
# Production Settings #
########################
@ -14,11 +16,11 @@
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.postgresql_psycopg2',
'NAME': 'note_db',
'USER': 'note',
'PASSWORD': 'update_in_env_variable',
'HOST': '127.0.0.1',
'PORT': '',
'NAME': os.environ.get('DJANGO_DB_NAME', 'note_db'),
'USER': os.environ.get('DJANGO_DB_USER', 'note'),
'PASSWORD': os.environ.get('DJANGO_DB_PASSWORD', 'CHANGE_ME_IN_ENV_SETTINGS'),
'HOST': os.environ.get('DJANGO_DB_HOST', 'localhost'),
'PORT': os.environ.get('DJANGO_DB_PORT', ''), # Use default port
}
}
@ -26,7 +28,9 @@ DATABASES = {
DEBUG = True
# Mandatory !
ALLOWED_HOSTS = ['127.0.0.1','note.comby.xyz']
ALLOWED_HOSTS = [os.environ.get('NOTE_URL', 'localhost')]
SECRET_KEY = os.environ.get('DJANGO_SECRET_KEY', 'CHANGE_ME_IN_ENV_SETTINGS')
# Emails
EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'
@ -37,7 +41,7 @@ EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'
# EMAIL_HOST_USER = 'change_me'
# EMAIL_HOST_PASSWORD = 'change_me'
SERVER_EMAIL = 'no-reply@example.org'
SERVER_EMAIL = 'no-reply@' + os.getenv("DOMAIN", "example.com")
# Security settings
SECURE_CONTENT_TYPE_NOSNIFF = False
@ -47,3 +51,6 @@ CSRF_COOKIE_SECURE = False
CSRF_COOKIE_HTTPONLY = False
X_FRAME_OPTIONS = 'DENY'
SESSION_COOKIE_AGE = 60 * 60 * 3
# CAS Client settings
CAS_SERVER_URL = "https://" + os.getenv("NOTE_URL", "note.example.com") + "/cas/"

View File

@ -0,0 +1,9 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
# CAS
OPTIONAL_APPS = [
# 'cas_server',
# 'cas',
# 'debug_toolbar'
]

View File

@ -1,10 +1,14 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from django.conf import settings
from django.conf.urls.static import static
from django.contrib import admin
from django.urls import path, include
from django.views.generic import RedirectView
from member.views import CustomLoginView
urlpatterns = [
# Dev so redirect to something random
path('', RedirectView.as_view(pattern_name='note:transfer'), name='index'),
@ -13,13 +17,37 @@ urlpatterns = [
path('note/', include('note.urls')),
path('accounts/', include('member.urls')),
path('activity/', include('activity.urls')),
path('treasury/', include('treasury.urls')),
# Include Django Contrib and Core routers
path('i18n/', include('django.conf.urls.i18n')),
path('accounts/', include('django.contrib.auth.urls')),
path('admin/doc/', include('django.contrib.admindocs.urls')),
path('admin/', admin.site.urls),
# Include Django REST API
path('accounts/', include('member.urls')),
path('accounts/login/', CustomLoginView.as_view()),
path('accounts/', include('django.contrib.auth.urls')),
path('api/', include('api.urls')),
]
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
if "cas_server" in settings.INSTALLED_APPS:
urlpatterns += [
# Include CAS Server routers
path('cas/', include('cas_server.urls', namespace="cas_server")),
]
if "cas" in settings.INSTALLED_APPS:
from cas import views as cas_views
urlpatterns += [
# Include CAS Client routers
path('accounts/login/cas/', cas_views.login, name='cas_login'),
path('accounts/logout/cas/', cas_views.logout, name='cas_logout'),
]
if "debug_toolbar" in settings.INSTALLED_APPS:
import debug_toolbar
urlpatterns = [
path('__debug__/', include(debug_toolbar.urls)),
] + urlpatterns

View File

@ -7,14 +7,9 @@ django-autocomplete-light==3.5.1
django-crispy-forms==1.7.2
django-extensions==2.1.9
django-filter==2.2.0
django-guardian==2.1.0
django-polymorphic==2.0.3
djangorestframework==3.9.0
django-rest-polymorphic==0.1.8
django-reversion==3.0.3
django-tables2==2.1.0
docutils==0.14
psycopg2==2.8.4
idna==2.8
oauthlib==3.1.0
Pillow==6.1.0
@ -24,4 +19,6 @@ requests==2.22.0
requests-oauthlib==1.2.0
six==1.12.0
sqlparse==0.3.0
djangorestframework==3.9.0
django-rest-polymorphic==0.1.8
urllib3==1.25.3

2
requirements/cas.txt Normal file
View File

@ -0,0 +1,2 @@
django-cas-client==1.5.3
django-cas-server==1.1.0

View File

@ -0,0 +1 @@
psycopg2-binary==2.8.4

View File

@ -0,0 +1,260 @@
select.admin-autocomplete {
width: 20em;
}
.select2-container--admin-autocomplete.select2-container {
min-height: 30px;
}
.select2-container--admin-autocomplete .select2-selection--single,
.select2-container--admin-autocomplete .select2-selection--multiple {
min-height: 30px;
padding: 0;
}
.select2-container--admin-autocomplete.select2-container--focus .select2-selection,
.select2-container--admin-autocomplete.select2-container--open .select2-selection {
border-color: #999;
min-height: 30px;
}
.select2-container--admin-autocomplete.select2-container--focus .select2-selection.select2-selection--single,
.select2-container--admin-autocomplete.select2-container--open .select2-selection.select2-selection--single {
padding: 0;
}
.select2-container--admin-autocomplete.select2-container--focus .select2-selection.select2-selection--multiple,
.select2-container--admin-autocomplete.select2-container--open .select2-selection.select2-selection--multiple {
padding: 0;
}
.select2-container--admin-autocomplete .select2-selection--single {
background-color: #fff;
border: 1px solid #ccc;
border-radius: 4px;
}
.select2-container--admin-autocomplete .select2-selection--single .select2-selection__rendered {
color: #444;
line-height: 30px;
}
.select2-container--admin-autocomplete .select2-selection--single .select2-selection__clear {
cursor: pointer;
float: right;
font-weight: bold;
}
.select2-container--admin-autocomplete .select2-selection--single .select2-selection__placeholder {
color: #999;
}
.select2-container--admin-autocomplete .select2-selection--single .select2-selection__arrow {
height: 26px;
position: absolute;
top: 1px;
right: 1px;
width: 20px;
}
.select2-container--admin-autocomplete .select2-selection--single .select2-selection__arrow b {
border-color: #888 transparent transparent transparent;
border-style: solid;
border-width: 5px 4px 0 4px;
height: 0;
left: 50%;
margin-left: -4px;
margin-top: -2px;
position: absolute;
top: 50%;
width: 0;
}
.select2-container--admin-autocomplete[dir="rtl"] .select2-selection--single .select2-selection__clear {
float: left;
}
.select2-container--admin-autocomplete[dir="rtl"] .select2-selection--single .select2-selection__arrow {
left: 1px;
right: auto;
}
.select2-container--admin-autocomplete.select2-container--disabled .select2-selection--single {
background-color: #eee;
cursor: default;
}
.select2-container--admin-autocomplete.select2-container--disabled .select2-selection--single .select2-selection__clear {
display: none;
}
.select2-container--admin-autocomplete.select2-container--open .select2-selection--single .select2-selection__arrow b {
border-color: transparent transparent #888 transparent;
border-width: 0 4px 5px 4px;
}
.select2-container--admin-autocomplete .select2-selection--multiple {
background-color: white;
border: 1px solid #ccc;
border-radius: 4px;
cursor: text;
}
.select2-container--admin-autocomplete .select2-selection--multiple .select2-selection__rendered {
box-sizing: border-box;
list-style: none;
margin: 0;
padding: 0 5px;
width: 100%;
}
.select2-container--admin-autocomplete .select2-selection--multiple .select2-selection__rendered li {
list-style: none;
}
.select2-container--admin-autocomplete .select2-selection--multiple .select2-selection__placeholder {
color: #999;
margin-top: 5px;
float: left;
}
.select2-container--admin-autocomplete .select2-selection--multiple .select2-selection__clear {
cursor: pointer;
float: right;
font-weight: bold;
margin: 5px;
}
.select2-container--admin-autocomplete .select2-selection--multiple .select2-selection__choice {
background-color: #e4e4e4;
border: 1px solid #ccc;
border-radius: 4px;
cursor: default;
float: left;
margin-right: 5px;
margin-top: 5px;
padding: 0 5px;
}
.select2-container--admin-autocomplete .select2-selection--multiple .select2-selection__choice__remove {
color: #999;
cursor: pointer;
display: inline-block;
font-weight: bold;
margin-right: 2px;
}
.select2-container--admin-autocomplete .select2-selection--multiple .select2-selection__choice__remove:hover {
color: #333;
}
.select2-container--admin-autocomplete[dir="rtl"] .select2-selection--multiple .select2-selection__choice, .select2-container--admin-autocomplete[dir="rtl"] .select2-selection--multiple .select2-selection__placeholder, .select2-container--admin-autocomplete[dir="rtl"] .select2-selection--multiple .select2-search--inline {
float: right;
}
.select2-container--admin-autocomplete[dir="rtl"] .select2-selection--multiple .select2-selection__choice {
margin-left: 5px;
margin-right: auto;
}
.select2-container--admin-autocomplete[dir="rtl"] .select2-selection--multiple .select2-selection__choice__remove {
margin-left: 2px;
margin-right: auto;
}
.select2-container--admin-autocomplete.select2-container--focus .select2-selection--multiple {
border: solid #999 1px;
outline: 0;
}
.select2-container--admin-autocomplete.select2-container--disabled .select2-selection--multiple {
background-color: #eee;
cursor: default;
}
.select2-container--admin-autocomplete.select2-container--disabled .select2-selection__choice__remove {
display: none;
}
.select2-container--admin-autocomplete.select2-container--open.select2-container--above .select2-selection--single, .select2-container--admin-autocomplete.select2-container--open.select2-container--above .select2-selection--multiple {
border-top-left-radius: 0;
border-top-right-radius: 0;
}
.select2-container--admin-autocomplete.select2-container--open.select2-container--below .select2-selection--single, .select2-container--admin-autocomplete.select2-container--open.select2-container--below .select2-selection--multiple {
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
}
.select2-container--admin-autocomplete .select2-search--dropdown .select2-search__field {
border: 1px solid #ccc;
}
.select2-container--admin-autocomplete .select2-search--inline .select2-search__field {
background: transparent;
border: none;
outline: 0;
box-shadow: none;
-webkit-appearance: textfield;
}
.select2-container--admin-autocomplete .select2-results > .select2-results__options {
max-height: 200px;
overflow-y: auto;
}
.select2-container--admin-autocomplete .select2-results__option[role=group] {
padding: 0;
}
.select2-container--admin-autocomplete .select2-results__option[aria-disabled=true] {
color: #999;
}
.select2-container--admin-autocomplete .select2-results__option[aria-selected=true] {
background-color: #ddd;
}
.select2-container--admin-autocomplete .select2-results__option .select2-results__option {
padding-left: 1em;
}
.select2-container--admin-autocomplete .select2-results__option .select2-results__option .select2-results__group {
padding-left: 0;
}
.select2-container--admin-autocomplete .select2-results__option .select2-results__option .select2-results__option {
margin-left: -1em;
padding-left: 2em;
}
.select2-container--admin-autocomplete .select2-results__option .select2-results__option .select2-results__option .select2-results__option {
margin-left: -2em;
padding-left: 3em;
}
.select2-container--admin-autocomplete .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option {
margin-left: -3em;
padding-left: 4em;
}
.select2-container--admin-autocomplete .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option {
margin-left: -4em;
padding-left: 5em;
}
.select2-container--admin-autocomplete .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option {
margin-left: -5em;
padding-left: 6em;
}
.select2-container--admin-autocomplete .select2-results__option--highlighted[aria-selected] {
background-color: #79aec8;
color: white;
}
.select2-container--admin-autocomplete .select2-results__group {
cursor: default;
display: block;
padding: 6px;
}

987
static/admin/css/base.css Normal file
View File

@ -0,0 +1,987 @@
/*
DJANGO Admin styles
*/
@import url(fonts.css);
body {
margin: 0;
padding: 0;
font-size: 14px;
font-family: "Roboto","Lucida Grande","DejaVu Sans","Bitstream Vera Sans",Verdana,Arial,sans-serif;
color: #333;
background: #fff;
}
/* LINKS */
a:link, a:visited {
color: #447e9b;
text-decoration: none;
}
a:focus, a:hover {
color: #036;
}
a:focus {
text-decoration: underline;
}
a img {
border: none;
}
a.section:link, a.section:visited {
color: #fff;
text-decoration: none;
}
a.section:focus, a.section:hover {
text-decoration: underline;
}
/* GLOBAL DEFAULTS */
p, ol, ul, dl {
margin: .2em 0 .8em 0;
}
p {
padding: 0;
line-height: 140%;
}
h1,h2,h3,h4,h5 {
font-weight: bold;
}
h1 {
margin: 0 0 20px;
font-weight: 300;
font-size: 20px;
color: #666;
}
h2 {
font-size: 16px;
margin: 1em 0 .5em 0;
}
h2.subhead {
font-weight: normal;
margin-top: 0;
}
h3 {
font-size: 14px;
margin: .8em 0 .3em 0;
color: #666;
font-weight: bold;
}
h4 {
font-size: 12px;
margin: 1em 0 .8em 0;
padding-bottom: 3px;
}
h5 {
font-size: 10px;
margin: 1.5em 0 .5em 0;
color: #666;
text-transform: uppercase;
letter-spacing: 1px;
}
ul li {
list-style-type: square;
padding: 1px 0;
}
li ul {
margin-bottom: 0;
}
li, dt, dd {
font-size: 13px;
line-height: 20px;
}
dt {
font-weight: bold;
margin-top: 4px;
}
dd {
margin-left: 0;
}
form {
margin: 0;
padding: 0;
}
fieldset {
margin: 0;
padding: 0;
border: none;
border-top: 1px solid #eee;
}
blockquote {
font-size: 11px;
color: #777;
margin-left: 2px;
padding-left: 10px;
border-left: 5px solid #ddd;
}
code, pre {
font-family: "Bitstream Vera Sans Mono", Monaco, "Courier New", Courier, monospace;
color: #666;
font-size: 12px;
}
pre.literal-block {
margin: 10px;
background: #eee;
padding: 6px 8px;
}
code strong {
color: #930;
}
hr {
clear: both;
color: #eee;
background-color: #eee;
height: 1px;
border: none;
margin: 0;
padding: 0;
font-size: 1px;
line-height: 1px;
}
/* TEXT STYLES & MODIFIERS */
.small {
font-size: 11px;
}
.tiny {
font-size: 10px;
}
p.tiny {
margin-top: -2px;
}
.mini {
font-size: 10px;
}
p.mini {
margin-top: -3px;
}
.help, p.help, form p.help, div.help, form div.help, div.help li {
font-size: 11px;
color: #999;
}
div.help ul {
margin-bottom: 0;
}
.help-tooltip {
cursor: help;
}
p img, h1 img, h2 img, h3 img, h4 img, td img {
vertical-align: middle;
}
.quiet, a.quiet:link, a.quiet:visited {
color: #999;
font-weight: normal;
}
.float-right {
float: right;
}
.float-left {
float: left;
}
.clear {
clear: both;
}
.align-left {
text-align: left;
}
.align-right {
text-align: right;
}
.example {
margin: 10px 0;
padding: 5px 10px;
background: #efefef;
}
.nowrap {
white-space: nowrap;
}
/* TABLES */
table {
border-collapse: collapse;
border-color: #ccc;
}
td, th {
font-size: 13px;
line-height: 16px;
border-bottom: 1px solid #eee;
vertical-align: top;
padding: 8px;
font-family: "Roboto", "Lucida Grande", Verdana, Arial, sans-serif;
}
th {
font-weight: 600;
text-align: left;
}
thead th,
tfoot td {
color: #666;
padding: 5px 10px;
font-size: 11px;
background: #fff;
border: none;
border-top: 1px solid #eee;
border-bottom: 1px solid #eee;
}
tfoot td {
border-bottom: none;
border-top: 1px solid #eee;
}
thead th.required {
color: #000;
}
tr.alt {
background: #f6f6f6;
}
.row1 {
background: #fff;
}
.row2 {
background: #f9f9f9;
}
/* SORTABLE TABLES */
thead th {
padding: 5px 10px;
line-height: normal;
text-transform: uppercase;
background: #f6f6f6;
}
thead th a:link, thead th a:visited {
color: #666;
}
thead th.sorted {
background: #eee;
}
thead th.sorted .text {
padding-right: 42px;
}
table thead th .text span {
padding: 8px 10px;
display: block;
}
table thead th .text a {
display: block;
cursor: pointer;
padding: 8px 10px;
}
table thead th .text a:focus, table thead th .text a:hover {
background: #eee;
}
thead th.sorted a.sortremove {
visibility: hidden;
}
table thead th.sorted:hover a.sortremove {
visibility: visible;
}
table thead th.sorted .sortoptions {
display: block;
padding: 9px 5px 0 5px;
float: right;
text-align: right;
}
table thead th.sorted .sortpriority {
font-size: .8em;
min-width: 12px;
text-align: center;
vertical-align: 3px;
margin-left: 2px;
margin-right: 2px;
}
table thead th.sorted .sortoptions a {
position: relative;
width: 14px;
height: 14px;
display: inline-block;
background: url(../img/sorting-icons.svg) 0 0 no-repeat;
background-size: 14px auto;
}
table thead th.sorted .sortoptions a.sortremove {
background-position: 0 0;
}
table thead th.sorted .sortoptions a.sortremove:after {
content: '\\';
position: absolute;
top: -6px;
left: 3px;
font-weight: 200;
font-size: 18px;
color: #999;
}
table thead th.sorted .sortoptions a.sortremove:focus:after,
table thead th.sorted .sortoptions a.sortremove:hover:after {
color: #447e9b;
}
table thead th.sorted .sortoptions a.sortremove:focus,
table thead th.sorted .sortoptions a.sortremove:hover {
background-position: 0 -14px;
}
table thead th.sorted .sortoptions a.ascending {
background-position: 0 -28px;
}
table thead th.sorted .sortoptions a.ascending:focus,
table thead th.sorted .sortoptions a.ascending:hover {
background-position: 0 -42px;
}
table thead th.sorted .sortoptions a.descending {
top: 1px;
background-position: 0 -56px;
}
table thead th.sorted .sortoptions a.descending:focus,
table thead th.sorted .sortoptions a.descending:hover {
background-position: 0 -70px;
}
/* FORM DEFAULTS */
input, textarea, select, .form-row p, form .button {
margin: 2px 0;
padding: 2px 3px;
vertical-align: middle;
font-family: "Roboto", "Lucida Grande", Verdana, Arial, sans-serif;
font-weight: normal;
font-size: 13px;
}
.form-row div.help {
padding: 2px 3px;
}
textarea {
vertical-align: top;
}
input[type=text], input[type=password], input[type=email], input[type=url],
input[type=number], input[type=tel], textarea, select, .vTextField {
border: 1px solid #ccc;
border-radius: 4px;
padding: 5px 6px;
margin-top: 0;
}
input[type=text]:focus, input[type=password]:focus, input[type=email]:focus,
input[type=url]:focus, input[type=number]:focus, input[type=tel]:focus,
textarea:focus, select:focus, .vTextField:focus {
border-color: #999;
}
select {
height: 30px;
}
select[multiple] {
/* Allow HTML size attribute to override the height in the rule above. */
height: auto;
min-height: 150px;
}
/* FORM BUTTONS */
.button, input[type=submit], input[type=button], .submit-row input, a.button {
background: #79aec8;
padding: 10px 15px;
border: none;
border-radius: 4px;
color: #fff;
cursor: pointer;
}
a.button {
padding: 4px 5px;
}
.button:active, input[type=submit]:active, input[type=button]:active,
.button:focus, input[type=submit]:focus, input[type=button]:focus,
.button:hover, input[type=submit]:hover, input[type=button]:hover {
background: #609ab6;
}
.button[disabled], input[type=submit][disabled], input[type=button][disabled] {
opacity: 0.4;
}
.button.default, input[type=submit].default, .submit-row input.default {
float: right;
border: none;
font-weight: 400;
background: #417690;
}
.button.default:active, input[type=submit].default:active,
.button.default:focus, input[type=submit].default:focus,
.button.default:hover, input[type=submit].default:hover {
background: #205067;
}
.button[disabled].default,
input[type=submit][disabled].default,
input[type=button][disabled].default {
opacity: 0.4;
}
/* MODULES */
.module {
border: none;
margin-bottom: 30px;
background: #fff;
}
.module p, .module ul, .module h3, .module h4, .module dl, .module pre {
padding-left: 10px;
padding-right: 10px;
}
.module blockquote {
margin-left: 12px;
}
.module ul, .module ol {
margin-left: 1.5em;
}
.module h3 {
margin-top: .6em;
}
.module h2, .module caption, .inline-group h2 {
margin: 0;
padding: 8px;
font-weight: 400;
font-size: 13px;
text-align: left;
background: #79aec8;
color: #fff;
}
.module caption,
.inline-group h2 {
font-size: 12px;
letter-spacing: 0.5px;
text-transform: uppercase;
}
.module table {
border-collapse: collapse;
}
/* MESSAGES & ERRORS */
ul.messagelist {
padding: 0;
margin: 0;
}
ul.messagelist li {
display: block;
font-weight: 400;
font-size: 13px;
padding: 10px 10px 10px 65px;
margin: 0 0 10px 0;
background: #dfd url(../img/icon-yes.svg) 40px 12px no-repeat;
background-size: 16px auto;
color: #333;
}
ul.messagelist li.warning {
background: #ffc url(../img/icon-alert.svg) 40px 14px no-repeat;
background-size: 14px auto;
}
ul.messagelist li.error {
background: #ffefef url(../img/icon-no.svg) 40px 12px no-repeat;
background-size: 16px auto;
}
.errornote {
font-size: 14px;
font-weight: 700;
display: block;
padding: 10px 12px;
margin: 0 0 10px 0;
color: #ba2121;
border: 1px solid #ba2121;
border-radius: 4px;
background-color: #fff;
background-position: 5px 12px;
}
ul.errorlist {
margin: 0 0 4px;
padding: 0;
color: #ba2121;
background: #fff;
}
ul.errorlist li {
font-size: 13px;
display: block;
margin-bottom: 4px;
}
ul.errorlist li:first-child {
margin-top: 0;
}
ul.errorlist li a {
color: inherit;
text-decoration: underline;
}
td ul.errorlist {
margin: 0;
padding: 0;
}
td ul.errorlist li {
margin: 0;
}
.form-row.errors {
margin: 0;
border: none;
border-bottom: 1px solid #eee;
background: none;
}
.form-row.errors ul.errorlist li {
padding-left: 0;
}
.errors input, .errors select, .errors textarea {
border: 1px solid #ba2121;
}
div.system-message {
background: #ffc;
margin: 10px;
padding: 6px 8px;
font-size: .8em;
}
div.system-message p.system-message-title {
padding: 4px 5px 4px 25px;
margin: 0;
color: #c11;
background: #ffefef url(../img/icon-no.svg) 5px 5px no-repeat;
}
.description {
font-size: 12px;
padding: 5px 0 0 12px;
}
/* BREADCRUMBS */
div.breadcrumbs {
background: #79aec8;
padding: 10px 40px;
border: none;
font-size: 14px;
color: #c4dce8;
text-align: left;
}
div.breadcrumbs a {
color: #fff;
}
div.breadcrumbs a:focus, div.breadcrumbs a:hover {
color: #c4dce8;
}
/* ACTION ICONS */
.viewlink, .inlineviewlink {
padding-left: 16px;
background: url(../img/icon-viewlink.svg) 0 1px no-repeat;
}
.addlink {
padding-left: 16px;
background: url(../img/icon-addlink.svg) 0 1px no-repeat;
}
.changelink, .inlinechangelink {
padding-left: 16px;
background: url(../img/icon-changelink.svg) 0 1px no-repeat;
}
.deletelink {
padding-left: 16px;
background: url(../img/icon-deletelink.svg) 0 1px no-repeat;
}
a.deletelink:link, a.deletelink:visited {
color: #CC3434;
}
a.deletelink:focus, a.deletelink:hover {
color: #993333;
text-decoration: none;
}
/* OBJECT TOOLS */
.object-tools {
font-size: 10px;
font-weight: bold;
padding-left: 0;
float: right;
position: relative;
margin-top: -48px;
}
.form-row .object-tools {
margin-top: 5px;
margin-bottom: 5px;
float: none;
height: 2em;
padding-left: 3.5em;
}
.object-tools li {
display: block;
float: left;
margin-left: 5px;
height: 16px;
}
.object-tools a {
border-radius: 15px;
}
.object-tools a:link, .object-tools a:visited {
display: block;
float: left;
padding: 3px 12px;
background: #999;
font-weight: 400;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.5px;
color: #fff;
}
.object-tools a:focus, .object-tools a:hover {
background-color: #417690;
}
.object-tools a:focus{
text-decoration: none;
}
.object-tools a.viewsitelink, .object-tools a.golink,.object-tools a.addlink {
background-repeat: no-repeat;
background-position: right 7px center;
padding-right: 26px;
}
.object-tools a.viewsitelink, .object-tools a.golink {
background-image: url(../img/tooltag-arrowright.svg);
}
.object-tools a.addlink {
background-image: url(../img/tooltag-add.svg);
}
/* OBJECT HISTORY */
table#change-history {
width: 100%;
}
table#change-history tbody th {
width: 16em;
}
/* PAGE STRUCTURE */
#container {
position: relative;
width: 100%;
min-width: 980px;
padding: 0;
}
#content {
padding: 20px 40px;
}
.dashboard #content {
width: 600px;
}
#content-main {
float: left;
width: 100%;
}
#content-related {
float: right;
width: 260px;
position: relative;
margin-right: -300px;
}
#footer {
clear: both;
padding: 10px;
}
/* COLUMN TYPES */
.colMS {
margin-right: 300px;
}
.colSM {
margin-left: 300px;
}
.colSM #content-related {
float: left;
margin-right: 0;
margin-left: -300px;
}
.colSM #content-main {
float: right;
}
.popup .colM {
width: auto;
}
/* HEADER */
#header {
width: auto;
height: auto;
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 40px;
background: #417690;
color: #ffc;
overflow: hidden;
}
#header a:link, #header a:visited {
color: #fff;
}
#header a:focus , #header a:hover {
text-decoration: underline;
}
#branding {
float: left;
}
#branding h1 {
padding: 0;
margin: 0 20px 0 0;
font-weight: 300;
font-size: 24px;
color: #f5dd5d;
}
#branding h1, #branding h1 a:link, #branding h1 a:visited {
color: #f5dd5d;
}
#branding h2 {
padding: 0 10px;
font-size: 14px;
margin: -8px 0 8px 0;
font-weight: normal;
color: #ffc;
}
#branding a:hover {
text-decoration: none;
}
#user-tools {
float: right;
padding: 0;
margin: 0 0 0 20px;
font-weight: 300;
font-size: 11px;
letter-spacing: 0.5px;
text-transform: uppercase;
text-align: right;
}
#user-tools a {
border-bottom: 1px solid rgba(255, 255, 255, 0.25);
}
#user-tools a:focus, #user-tools a:hover {
text-decoration: none;
border-bottom-color: #79aec8;
color: #79aec8;
}
/* SIDEBAR */
#content-related {
background: #f8f8f8;
}
#content-related .module {
background: none;
}
#content-related h3 {
font-size: 14px;
color: #666;
padding: 0 16px;
margin: 0 0 16px;
}
#content-related h4 {
font-size: 13px;
}
#content-related p {
padding-left: 16px;
padding-right: 16px;
}
#content-related .actionlist {
padding: 0;
margin: 16px;
}
#content-related .actionlist li {
line-height: 1.2;
margin-bottom: 10px;
padding-left: 18px;
}
#content-related .module h2 {
background: none;
padding: 16px;
margin-bottom: 16px;
border-bottom: 1px solid #eaeaea;
font-size: 18px;
color: #333;
}
.delete-confirmation form input[type="submit"] {
background: #ba2121;
border-radius: 4px;
padding: 10px 15px;
color: #fff;
}
.delete-confirmation form input[type="submit"]:active,
.delete-confirmation form input[type="submit"]:focus,
.delete-confirmation form input[type="submit"]:hover {
background: #a41515;
}
.delete-confirmation form .cancel-link {
display: inline-block;
vertical-align: middle;
height: 15px;
line-height: 15px;
background: #ddd;
border-radius: 4px;
padding: 10px 15px;
color: #333;
margin: 0 0 0 10px;
}
.delete-confirmation form .cancel-link:active,
.delete-confirmation form .cancel-link:focus,
.delete-confirmation form .cancel-link:hover {
background: #ccc;
}
/* POPUP */
.popup #content {
padding: 20px;
}
.popup #container {
min-width: 0;
}
.popup #header {
padding: 10px 20px;
}

View File

@ -0,0 +1,344 @@
/* CHANGELISTS */
#changelist {
position: relative;
width: 100%;
}
#changelist table {
width: 100%;
}
.change-list .hiddenfields { display:none; }
.change-list .filtered table {
border-right: none;
}
.change-list .filtered {
min-height: 400px;
}
.change-list .filtered .results, .change-list .filtered .paginator,
.filtered #toolbar, .filtered div.xfull {
margin-right: 280px;
width: auto;
}
.change-list .filtered table tbody th {
padding-right: 1em;
}
#changelist-form .results {
overflow-x: auto;
}
#changelist .toplinks {
border-bottom: 1px solid #ddd;
}
#changelist .paginator {
color: #666;
border-bottom: 1px solid #eee;
background: #fff;
overflow: hidden;
}
/* CHANGELIST TABLES */
#changelist table thead th {
padding: 0;
white-space: nowrap;
vertical-align: middle;
}
#changelist table thead th.action-checkbox-column {
width: 1.5em;
text-align: center;
}
#changelist table tbody td.action-checkbox {
text-align: center;
}
#changelist table tfoot {
color: #666;
}
/* TOOLBAR */
#changelist #toolbar {
padding: 8px 10px;
margin-bottom: 15px;
border-top: 1px solid #eee;
border-bottom: 1px solid #eee;
background: #f8f8f8;
color: #666;
}
#changelist #toolbar form input {
border-radius: 4px;
font-size: 14px;
padding: 5px;
color: #333;
}
#changelist #toolbar form #searchbar {
height: 19px;
border: 1px solid #ccc;
padding: 2px 5px;
margin: 0;
vertical-align: top;
font-size: 13px;
}
#changelist #toolbar form #searchbar:focus {
border-color: #999;
}
#changelist #toolbar form input[type="submit"] {
border: 1px solid #ccc;
padding: 2px 10px;
margin: 0;
vertical-align: middle;
background: #fff;
box-shadow: 0 -15px 20px -10px rgba(0, 0, 0, 0.15) inset;
cursor: pointer;
color: #333;
}
#changelist #toolbar form input[type="submit"]:focus,
#changelist #toolbar form input[type="submit"]:hover {
border-color: #999;
}
#changelist #changelist-search img {
vertical-align: middle;
margin-right: 4px;
}
/* FILTER COLUMN */
#changelist-filter {
position: absolute;
top: 0;
right: 0;
z-index: 1000;
width: 240px;
background: #f8f8f8;
border-left: none;
margin: 0;
}
#changelist-filter h2 {
font-size: 14px;
text-transform: uppercase;
letter-spacing: 0.5px;
padding: 5px 15px;
margin-bottom: 12px;
border-bottom: none;
}
#changelist-filter h3 {
font-weight: 400;
font-size: 14px;
padding: 0 15px;
margin-bottom: 10px;
}
#changelist-filter ul {
margin: 5px 0;
padding: 0 15px 15px;
border-bottom: 1px solid #eaeaea;
}
#changelist-filter ul:last-child {
border-bottom: none;
padding-bottom: none;
}
#changelist-filter li {
list-style-type: none;
margin-left: 0;
padding-left: 0;
}
#changelist-filter a {
display: block;
color: #999;
text-overflow: ellipsis;
overflow-x: hidden;
}
#changelist-filter li.selected {
border-left: 5px solid #eaeaea;
padding-left: 10px;
margin-left: -15px;
}
#changelist-filter li.selected a {
color: #5b80b2;
}
#changelist-filter a:focus, #changelist-filter a:hover,
#changelist-filter li.selected a:focus,
#changelist-filter li.selected a:hover {
color: #036;
}
/* DATE DRILLDOWN */
.change-list ul.toplinks {
display: block;
float: left;
padding: 0;
margin: 0;
width: 100%;
}
.change-list ul.toplinks li {
padding: 3px 6px;
font-weight: bold;
list-style-type: none;
display: inline-block;
}
.change-list ul.toplinks .date-back a {
color: #999;
}
.change-list ul.toplinks .date-back a:focus,
.change-list ul.toplinks .date-back a:hover {
color: #036;
}
/* PAGINATOR */
.paginator {
font-size: 13px;
padding-top: 10px;
padding-bottom: 10px;
line-height: 22px;
margin: 0;
border-top: 1px solid #ddd;
}
.paginator a:link, .paginator a:visited {
padding: 2px 6px;
background: #79aec8;
text-decoration: none;
color: #fff;
}
.paginator a.showall {
padding: 0;
border: none;
background: none;
color: #5b80b2;
}
.paginator a.showall:focus, .paginator a.showall:hover {
background: none;
color: #036;
}
.paginator .end {
margin-right: 6px;
}
.paginator .this-page {
padding: 2px 6px;
font-weight: bold;
font-size: 13px;
vertical-align: top;
}
.paginator a:focus, .paginator a:hover {
color: white;
background: #036;
}
/* ACTIONS */
.filtered .actions {
margin-right: 280px;
border-right: none;
}
#changelist table input {
margin: 0;
vertical-align: baseline;
}
#changelist table tbody tr.selected {
background-color: #FFFFCC;
}
#changelist .actions {
padding: 10px;
background: #fff;
border-top: none;
border-bottom: none;
line-height: 24px;
color: #999;
}
#changelist .actions.selected {
background: #fffccf;
border-top: 1px solid #fffee8;
border-bottom: 1px solid #edecd6;
}
#changelist .actions span.all,
#changelist .actions span.action-counter,
#changelist .actions span.clear,
#changelist .actions span.question {
font-size: 13px;
margin: 0 0.5em;
display: none;
}
#changelist .actions:last-child {
border-bottom: none;
}
#changelist .actions select {
vertical-align: top;
height: 24px;
background: none;
color: #000;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 14px;
padding: 0 0 0 4px;
margin: 0;
margin-left: 10px;
}
#changelist .actions select:focus {
border-color: #999;
}
#changelist .actions label {
display: inline-block;
vertical-align: middle;
font-size: 13px;
}
#changelist .actions .button {
font-size: 13px;
border: 1px solid #ccc;
border-radius: 4px;
background: #fff;
box-shadow: 0 -15px 20px -10px rgba(0, 0, 0, 0.15) inset;
cursor: pointer;
height: 24px;
line-height: 1;
padding: 4px 8px;
margin: 0;
color: #333;
}
#changelist .actions .button:focus, #changelist .actions .button:hover {
border-color: #999;
}

View File

@ -0,0 +1,27 @@
/* DASHBOARD */
.dashboard .module table th {
width: 100%;
}
.dashboard .module table td {
white-space: nowrap;
}
.dashboard .module table td a {
display: block;
padding-right: .6em;
}
/* RECENT ACTIONS MODULE */
.module ul.actionlist {
margin-left: 0;
}
ul.actionlist li {
list-style-type: none;
overflow: hidden;
text-overflow: ellipsis;
-o-text-overflow: ellipsis;
}

View File

@ -0,0 +1,20 @@
@font-face {
font-family: 'Roboto';
src: url('../fonts/Roboto-Bold-webfont.woff');
font-weight: 700;
font-style: normal;
}
@font-face {
font-family: 'Roboto';
src: url('../fonts/Roboto-Regular-webfont.woff');
font-weight: 400;
font-style: normal;
}
@font-face {
font-family: 'Roboto';
src: url('../fonts/Roboto-Light-webfont.woff');
font-weight: 300;
font-style: normal;
}

532
static/admin/css/forms.css Normal file
View File

@ -0,0 +1,532 @@
@import url('widgets.css');
/* FORM ROWS */
.form-row {
overflow: hidden;
padding: 10px;
font-size: 13px;
border-bottom: 1px solid #eee;
}
.form-row img, .form-row input {
vertical-align: middle;
}
.form-row label input[type="checkbox"] {
margin-top: 0;
vertical-align: 0;
}
form .form-row p {
padding-left: 0;
}
.hidden {
display: none;
}
/* FORM LABELS */
label {
font-weight: normal;
color: #666;
font-size: 13px;
}
.required label, label.required {
font-weight: bold;
color: #333;
}
/* RADIO BUTTONS */
form ul.radiolist li {
list-style-type: none;
}
form ul.radiolist label {
float: none;
display: inline;
}
form ul.radiolist input[type="radio"] {
margin: -2px 4px 0 0;
padding: 0;
}
form ul.inline {
margin-left: 0;
padding: 0;
}
form ul.inline li {
float: left;
padding-right: 7px;
}
/* ALIGNED FIELDSETS */
.aligned label {
display: block;
padding: 4px 10px 0 0;
float: left;
width: 160px;
word-wrap: break-word;
line-height: 1;
}
.aligned label:not(.vCheckboxLabel):after {
content: '';
display: inline-block;
vertical-align: middle;
height: 26px;
}
.aligned label + p, .aligned label + div.help, .aligned label + div.readonly {
padding: 6px 0;
margin-top: 0;
margin-bottom: 0;
margin-left: 170px;
}
.aligned ul label {
display: inline;
float: none;
width: auto;
}
.aligned .form-row input {
margin-bottom: 0;
}
.colMS .aligned .vLargeTextField, .colMS .aligned .vXMLLargeTextField {
width: 350px;
}
form .aligned ul {
margin-left: 160px;
padding-left: 10px;
}
form .aligned ul.radiolist {
display: inline-block;
margin: 0;
padding: 0;
}
form .aligned p.help,
form .aligned div.help {
clear: left;
margin-top: 0;
margin-left: 160px;
padding-left: 10px;
}
form .aligned label + p.help,
form .aligned label + div.help {
margin-left: 0;
padding-left: 0;
}
form .aligned p.help:last-child,
form .aligned div.help:last-child {
margin-bottom: 0;
padding-bottom: 0;
}
form .aligned input + p.help,
form .aligned textarea + p.help,
form .aligned select + p.help,
form .aligned input + div.help,
form .aligned textarea + div.help,
form .aligned select + div.help {
margin-left: 160px;
padding-left: 10px;
}
form .aligned ul li {
list-style: none;
}
form .aligned table p {
margin-left: 0;
padding-left: 0;
}
.aligned .vCheckboxLabel {
float: none;
width: auto;
display: inline-block;
vertical-align: -3px;
padding: 0 0 5px 5px;
}
.aligned .vCheckboxLabel + p.help,
.aligned .vCheckboxLabel + div.help {
margin-top: -4px;
}
.colM .aligned .vLargeTextField, .colM .aligned .vXMLLargeTextField {
width: 610px;
}
.checkbox-row p.help,
.checkbox-row div.help {
margin-left: 0;
padding-left: 0;
}
fieldset .fieldBox {
float: left;
margin-right: 20px;
}
/* WIDE FIELDSETS */
.wide label {
width: 200px;
}
form .wide p,
form .wide input + p.help,
form .wide input + div.help {
margin-left: 200px;
}
form .wide p.help,
form .wide div.help {
padding-left: 38px;
}
form div.help ul {
padding-left: 0;
margin-left: 0;
}
.colM fieldset.wide .vLargeTextField, .colM fieldset.wide .vXMLLargeTextField {
width: 450px;
}
/* COLLAPSED FIELDSETS */
fieldset.collapsed * {
display: none;
}
fieldset.collapsed h2, fieldset.collapsed {
display: block;
}
fieldset.collapsed {
border: 1px solid #eee;
border-radius: 4px;
overflow: hidden;
}
fieldset.collapsed h2 {
background: #f8f8f8;
color: #666;
}
fieldset .collapse-toggle {
color: #fff;
}
fieldset.collapsed .collapse-toggle {
background: transparent;
display: inline;
color: #447e9b;
}
/* MONOSPACE TEXTAREAS */
fieldset.monospace textarea {
font-family: "Bitstream Vera Sans Mono", Monaco, "Courier New", Courier, monospace;
}
/* SUBMIT ROW */
.submit-row {
padding: 12px 14px;
margin: 0 0 20px;
background: #f8f8f8;
border: 1px solid #eee;
border-radius: 4px;
text-align: right;
overflow: hidden;
}
body.popup .submit-row {
overflow: auto;
}
.submit-row input {
height: 35px;
line-height: 15px;
margin: 0 0 0 5px;
}
.submit-row input.default {
margin: 0 0 0 8px;
text-transform: uppercase;
}
.submit-row p {
margin: 0.3em;
}
.submit-row p.deletelink-box {
float: left;
margin: 0;
}
.submit-row a.deletelink {
display: block;
background: #ba2121;
border-radius: 4px;
padding: 10px 15px;
height: 15px;
line-height: 15px;
color: #fff;
}
.submit-row a.closelink {
display: inline-block;
background: #bbbbbb;
border-radius: 4px;
padding: 10px 15px;
height: 15px;
line-height: 15px;
margin: 0 0 0 5px;
color: #fff;
}
.submit-row a.deletelink:focus,
.submit-row a.deletelink:hover,
.submit-row a.deletelink:active {
background: #a41515;
}
.submit-row a.closelink:focus,
.submit-row a.closelink:hover,
.submit-row a.closelink:active {
background: #aaaaaa;
}
/* CUSTOM FORM FIELDS */
.vSelectMultipleField {
vertical-align: top;
}
.vCheckboxField {
border: none;
}
.vDateField, .vTimeField {
margin-right: 2px;
margin-bottom: 4px;
}
.vDateField {
min-width: 6.85em;
}
.vTimeField {
min-width: 4.7em;
}
.vURLField {
width: 30em;
}
.vLargeTextField, .vXMLLargeTextField {
width: 48em;
}
.flatpages-flatpage #id_content {
height: 40.2em;
}
.module table .vPositiveSmallIntegerField {
width: 2.2em;
}
.vTextField, .vUUIDField {
width: 20em;
}
.vIntegerField {
width: 5em;
}
.vBigIntegerField {
width: 10em;
}
.vForeignKeyRawIdAdminField {
width: 5em;
}
/* INLINES */
.inline-group {
padding: 0;
margin: 0 0 30px;
}
.inline-group thead th {
padding: 8px 10px;
}
.inline-group .aligned label {
width: 160px;
}
.inline-related {
position: relative;
}
.inline-related h3 {
margin: 0;
color: #666;
padding: 5px;
font-size: 13px;
background: #f8f8f8;
border-top: 1px solid #eee;
border-bottom: 1px solid #eee;
}
.inline-related h3 span.delete {
float: right;
}
.inline-related h3 span.delete label {
margin-left: 2px;
font-size: 11px;
}
.inline-related fieldset {
margin: 0;
background: #fff;
border: none;
width: 100%;
}
.inline-related fieldset.module h3 {
margin: 0;
padding: 2px 5px 3px 5px;
font-size: 11px;
text-align: left;
font-weight: bold;
background: #bcd;
color: #fff;
}
.inline-group .tabular fieldset.module {
border: none;
}
.inline-related.tabular fieldset.module table {
width: 100%;
}
.last-related fieldset {
border: none;
}
.inline-group .tabular tr.has_original td {
padding-top: 2em;
}
.inline-group .tabular tr td.original {
padding: 2px 0 0 0;
width: 0;
_position: relative;
}
.inline-group .tabular th.original {
width: 0px;
padding: 0;
}
.inline-group .tabular td.original p {
position: absolute;
left: 0;
height: 1.1em;
padding: 2px 9px;
overflow: hidden;
font-size: 9px;
font-weight: bold;
color: #666;
_width: 700px;
}
.inline-group ul.tools {
padding: 0;
margin: 0;
list-style: none;
}
.inline-group ul.tools li {
display: inline;
padding: 0 5px;
}
.inline-group div.add-row,
.inline-group .tabular tr.add-row td {
color: #666;
background: #f8f8f8;
padding: 8px 10px;
border-bottom: 1px solid #eee;
}
.inline-group .tabular tr.add-row td {
padding: 8px 10px;
border-bottom: 1px solid #eee;
}
.inline-group ul.tools a.add,
.inline-group div.add-row a,
.inline-group .tabular tr.add-row td a {
background: url(../img/icon-addlink.svg) 0 1px no-repeat;
padding-left: 16px;
font-size: 12px;
}
.empty-form {
display: none;
}
/* RELATED FIELD ADD ONE / LOOKUP */
.add-another, .related-lookup {
margin-left: 5px;
display: inline-block;
vertical-align: middle;
background-repeat: no-repeat;
background-size: 14px;
}
.add-another {
width: 16px;
height: 16px;
background-image: url(../img/icon-addlink.svg);
}
.related-lookup {
width: 16px;
height: 16px;
background-image: url(../img/search.svg);
}
form .related-widget-wrapper ul {
display: inline-block;
margin-left: 0;
padding-left: 0;
}
.clearable-file-input input {
margin-top: 0;
}

Some files were not shown because too many files have changed in this diff Show More