92 lines
3.7 KiB
Python
92 lines
3.7 KiB
Python
# admin.py
|
||
from django import forms
|
||
from django.utils import timezone
|
||
from django.utils.html import escape, mark_safe
|
||
from django.contrib import admin
|
||
from django.contrib.admin.widgets import AdminSplitDateTime
|
||
|
||
from .models import PersonalAccessToken, _sha256 # _sha256 dispo dans models.py
|
||
|
||
import secrets
|
||
|
||
_HTML5_FMT = "%Y-%m-%dT%H:%M" # sans secondes ni timezone (exigence de datetime-local)
|
||
|
||
class PersonalAccessTokenForm(forms.ModelForm):
|
||
# Non obligatoire: on le remplit nous-mêmes si vide
|
||
token_hash = forms.CharField(required=False, widget=forms.TextInput(attrs={"readonly": "readonly"}))
|
||
# Aide à la saisie: durée si expires_at non remplit
|
||
expires_at = forms.DateTimeField(
|
||
required=False,
|
||
input_formats=[_HTML5_FMT], # pour le POST
|
||
widget=forms.DateTimeInput(
|
||
attrs={"type": "datetime-local"},
|
||
format=_HTML5_FMT, # pour l’affichage initial
|
||
),
|
||
)
|
||
ttl_days = forms.IntegerField(required=False, min_value=1, initial=30, help_text="Utilisé si 'expires_at' est vide.")
|
||
|
||
class Meta:
|
||
model = PersonalAccessToken
|
||
fields = ("user", "name", "collections", "expires_at", "revoked", "token_hash")
|
||
widgets = {
|
||
# JSONField par défaut convient; sinon remplacer par un Textarea
|
||
# "collections": forms.Textarea(attrs={"rows": 2}),
|
||
}
|
||
|
||
def clean(self):
|
||
cleaned = super().clean()
|
||
expires_at = cleaned.get("expires_at")
|
||
ttl_days = cleaned.get("ttl_days")
|
||
if not expires_at:
|
||
# Si pas de date fournie, calcule via ttl_days (ou 30 par défaut)
|
||
days = ttl_days or 30
|
||
cleaned["expires_at"] = timezone.now() + timezone.timedelta(days=days)
|
||
return cleaned
|
||
|
||
|
||
@admin.register(PersonalAccessToken)
|
||
class PersonalAccessTokenAdmin(admin.ModelAdmin):
|
||
form = PersonalAccessTokenForm
|
||
list_display = ("user", "name", "expires_at", "revoked", "created_at")
|
||
list_filter = ("revoked",)
|
||
search_fields = ("user__username", "user__email", "name")
|
||
readonly_fields = ("token_hash",) # visible mais non éditable
|
||
|
||
actions = ["revoke_tokens"]
|
||
|
||
@admin.action(description="Révoquer les tokens sélectionnés")
|
||
def revoke_tokens(self, request, queryset):
|
||
updated = queryset.update(revoked=True)
|
||
self.message_user(request, f"{updated} token(s) révoqué(s).")
|
||
|
||
def save_model(self, request, obj, form, change):
|
||
"""
|
||
À la création, si token_hash est vide:
|
||
- Génère un raw token 'pat_...'
|
||
- Hash en SHA-256 (même logique que le modèle)
|
||
- Sauve l'objet
|
||
- Affiche le raw token une seule fois dans un message admin
|
||
"""
|
||
raw_token_to_display = None
|
||
|
||
if not change and not obj.token_hash:
|
||
# Génère un raw token (même préfixe que la méthode issue)
|
||
raw = "pat_" + secrets.token_urlsafe(32)
|
||
obj.token_hash = _sha256(raw) # même algo que le modèle
|
||
raw_token_to_display = raw
|
||
|
||
# Remise en forme des collections: set trié, comme dans issue()
|
||
if obj.collections:
|
||
obj.collections = sorted(set(obj.collections))
|
||
|
||
# Si l'admin n'a pas rempli expires_at, le form l'a calculé via ttl_days
|
||
|
||
super().save_model(request, obj, form, change)
|
||
|
||
if raw_token_to_display:
|
||
# Affiche le raw token (copiable) + l'expiration
|
||
message = f"Token généré pour {obj.user}: {raw_token_to_display}\nExpire le {obj.expires_at:%Y-%m-%d %H:%M}"
|
||
self.message_user(
|
||
request,
|
||
mark_safe("<pre style='white-space:pre-wrap'>"+escape(message)+"</pre>")
|
||
)
|