django-core-micha 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- django_core_micha-0.1.0/PKG-INFO +10 -0
- django_core_micha-0.1.0/README.md +0 -0
- django_core_micha-0.1.0/pyproject.toml +25 -0
- django_core_micha-0.1.0/setup.cfg +4 -0
- django_core_micha-0.1.0/src/django-core-micha/__init__.py +0 -0
- django_core_micha-0.1.0/src/django-core-micha/auth/__init__.py +0 -0
- django_core_micha-0.1.0/src/django-core-micha/auth/adapters.py +9 -0
- django_core_micha-0.1.0/src/django-core-micha/auth/apps.py +8 -0
- django_core_micha-0.1.0/src/django-core-micha/auth/models.py +18 -0
- django_core_micha-0.1.0/src/django-core-micha/auth/signals.py +39 -0
- django_core_micha-0.1.0/src/django-core-micha/invitations/__init__.py +0 -0
- django_core_micha-0.1.0/src/django-core-micha/invitations/apps.py +7 -0
- django_core_micha-0.1.0/src/django-core-micha/invitations/emails.py +60 -0
- django_core_micha-0.1.0/src/django-core-micha/invitations/mixins.py +188 -0
- django_core_micha-0.1.0/src/django-core-micha/invitations/serializers.py +6 -0
- django_core_micha-0.1.0/src/django-core-micha/invitations/views.py +87 -0
- django_core_micha-0.1.0/src/django-core-micha/scripts/__init__.py +0 -0
- django_core_micha-0.1.0/src/django-core-micha/scripts/generate_env.py +204 -0
- django_core_micha-0.1.0/src/django-core-micha/settings/__init__.py +0 -0
- django_core_micha-0.1.0/src/django-core-micha/settings/defaults.py +0 -0
- django_core_micha-0.1.0/src/django_core_micha.egg-info/PKG-INFO +10 -0
- django_core_micha-0.1.0/src/django_core_micha.egg-info/SOURCES.txt +24 -0
- django_core_micha-0.1.0/src/django_core_micha.egg-info/dependency_links.txt +1 -0
- django_core_micha-0.1.0/src/django_core_micha.egg-info/entry_points.txt +2 -0
- django_core_micha-0.1.0/src/django_core_micha.egg-info/requires.txt +3 -0
- django_core_micha-0.1.0/src/django_core_micha.egg-info/top_level.txt +2 -0
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: django-core-micha
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Core utilities and settings for Django Apps
|
|
5
|
+
Author-email: Micha Bigler <micha.bigler2@gmail.com>
|
|
6
|
+
Project-URL: Homepage, https://github.com/MichaBigler/webapp-management
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Requires-Dist: Django>=4.0
|
|
9
|
+
Requires-Dist: PyYAML
|
|
10
|
+
Requires-Dist: psycopg2-binary
|
|
File without changes
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "django-core-micha"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
authors = [
|
|
9
|
+
{ name="Micha Bigler", email="micha.bigler2@gmail.com" },
|
|
10
|
+
]
|
|
11
|
+
description = "Core utilities and settings for Django Apps"
|
|
12
|
+
requires-python = ">=3.10"
|
|
13
|
+
dependencies = [
|
|
14
|
+
"Django>=4.0",
|
|
15
|
+
"PyYAML",
|
|
16
|
+
"psycopg2-binary",
|
|
17
|
+
# Weitere gemeinsame Libs hier (z.B. djangorestframework)
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
[project.scripts]
|
|
21
|
+
# Ermöglicht Aufruf via Terminal: "generate-env"
|
|
22
|
+
generate-env = "django_core.scripts.generate_env:main"
|
|
23
|
+
|
|
24
|
+
[project.urls]
|
|
25
|
+
Homepage = "https://github.com/MichaBigler/webapp-management"
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
from allauth.socialaccount.adapter import DefaultSocialAccountAdapter
|
|
2
|
+
from django.contrib.auth import get_user_model
|
|
3
|
+
|
|
4
|
+
class InvitationOnlySocialAdapter(DefaultSocialAccountAdapter):
|
|
5
|
+
def is_open_for_signup(self, request, sociallogin):
|
|
6
|
+
User = get_user_model()
|
|
7
|
+
email = sociallogin.user.email
|
|
8
|
+
# Erlaubt Login nur, wenn User schon existiert
|
|
9
|
+
return User.objects.filter(email__iexact=email).exists()
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from django.db import models
|
|
2
|
+
from django.conf import settings
|
|
3
|
+
|
|
4
|
+
class AbstractUserProfile(models.Model):
|
|
5
|
+
"""
|
|
6
|
+
Abstrakte Basisklasse. Definiert Standardfelder für alle Projekte.
|
|
7
|
+
"""
|
|
8
|
+
user = models.OneToOneField(
|
|
9
|
+
settings.AUTH_USER_MODEL,
|
|
10
|
+
on_delete=models.CASCADE,
|
|
11
|
+
related_name="profile"
|
|
12
|
+
)
|
|
13
|
+
is_new = models.BooleanField(default=True)
|
|
14
|
+
accepted_privacy_statement = models.BooleanField(default=False)
|
|
15
|
+
accepted_convenience_cookies = models.BooleanField(default=False)
|
|
16
|
+
|
|
17
|
+
class Meta:
|
|
18
|
+
abstract = True # WICHTIG: Erstellt keine Tabelle in der DB
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
from django.conf import settings
|
|
2
|
+
from django.db.models.signals import pre_save
|
|
3
|
+
from django.dispatch import receiver
|
|
4
|
+
from django.contrib.auth import get_user_model
|
|
5
|
+
from allauth.socialaccount.signals import pre_social_login
|
|
6
|
+
import logging
|
|
7
|
+
|
|
8
|
+
logger = logging.getLogger(__name__)
|
|
9
|
+
|
|
10
|
+
@receiver(pre_save, sender=settings.AUTH_USER_MODEL)
|
|
11
|
+
def prevent_password_wipe(sender, instance, **kwargs):
|
|
12
|
+
# ... (Dein Code von vorhin: Password Wipe Prevention) ...
|
|
13
|
+
if instance.pk:
|
|
14
|
+
try:
|
|
15
|
+
old_user = sender.objects.get(pk=instance.pk)
|
|
16
|
+
has_old_pw = old_user.password and not old_user.password.startswith('!')
|
|
17
|
+
is_wiping_pw = not instance.password or instance.password.startswith('!')
|
|
18
|
+
|
|
19
|
+
if has_old_pw and is_wiping_pw:
|
|
20
|
+
instance.password = old_user.password
|
|
21
|
+
logger.info(f"Prevented password wipe for user {instance.email}")
|
|
22
|
+
except sender.DoesNotExist:
|
|
23
|
+
pass
|
|
24
|
+
|
|
25
|
+
@receiver(pre_social_login)
|
|
26
|
+
def force_auto_connect_on_email_match(sender, request, sociallogin, **kwargs):
|
|
27
|
+
# ... (Dein Code von vorhin: Force Auto Connect) ...
|
|
28
|
+
if sociallogin.is_existing or not sociallogin.email_addresses:
|
|
29
|
+
return
|
|
30
|
+
|
|
31
|
+
social_email = sociallogin.email_addresses[0].email
|
|
32
|
+
User = get_user_model()
|
|
33
|
+
|
|
34
|
+
try:
|
|
35
|
+
user = User.objects.get(email__iexact=social_email)
|
|
36
|
+
sociallogin.connect(request, user)
|
|
37
|
+
logger.info(f"Auto-connected social account for {social_email}")
|
|
38
|
+
except User.DoesNotExist:
|
|
39
|
+
pass
|
|
File without changes
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# django_core/invitations/emails.py
|
|
2
|
+
from django.conf import settings
|
|
3
|
+
from django.core.mail import EmailMessage
|
|
4
|
+
from django.template.loader import render_to_string
|
|
5
|
+
import logging
|
|
6
|
+
|
|
7
|
+
logger = logging.getLogger(__name__)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def send_invite_or_reset_email(*, user, url, is_new_user: bool) -> None:
|
|
11
|
+
"""
|
|
12
|
+
Generic helper: nimmt User + Link, holt sich Texte aus Templates
|
|
13
|
+
und schickt die Mail raus.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
ctx = {
|
|
17
|
+
"user": user,
|
|
18
|
+
"url": url,
|
|
19
|
+
"is_new_user": is_new_user,
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
# 1) Subjekt
|
|
23
|
+
if is_new_user:
|
|
24
|
+
subject_template = "invitations/invite_subject.txt"
|
|
25
|
+
body_template = "invitations/invite_body.txt"
|
|
26
|
+
else:
|
|
27
|
+
subject_template = "invitations/reset_subject.txt"
|
|
28
|
+
body_template = "invitations/reset_body.txt"
|
|
29
|
+
|
|
30
|
+
try:
|
|
31
|
+
subject = render_to_string(subject_template, ctx).strip()
|
|
32
|
+
body = render_to_string(body_template, ctx)
|
|
33
|
+
except Exception:
|
|
34
|
+
# Fallback, falls Templates fehlen (z. B. im frühen Setup)
|
|
35
|
+
if is_new_user:
|
|
36
|
+
subject = "Willkommen"
|
|
37
|
+
body = f"Hallo,\n\nbitte setze dein Passwort hier:\n{url}\n"
|
|
38
|
+
else:
|
|
39
|
+
subject = "Passwort zurücksetzen"
|
|
40
|
+
body = f"Hallo,\n\nbitte setze dein Passwort hier:\n{url}\n"
|
|
41
|
+
|
|
42
|
+
if getattr(settings, "ENV_TYPE", "") == "development":
|
|
43
|
+
logger.info("[DEV] Invite/Reset-Mail an %s: %s", user.email, url)
|
|
44
|
+
return
|
|
45
|
+
|
|
46
|
+
from_email = getattr(settings, "INVITATIONS_FROM_EMAIL", None)
|
|
47
|
+
reply_to = getattr(settings, "INVITATIONS_REPLY_TO", None)
|
|
48
|
+
|
|
49
|
+
headers = {}
|
|
50
|
+
if reply_to:
|
|
51
|
+
headers["Reply-To"] = reply_to
|
|
52
|
+
|
|
53
|
+
email = EmailMessage(
|
|
54
|
+
subject=subject,
|
|
55
|
+
body=body,
|
|
56
|
+
from_email=from_email, # None → DEFAULT_FROM_EMAIL
|
|
57
|
+
to=[user.email],
|
|
58
|
+
headers=headers,
|
|
59
|
+
)
|
|
60
|
+
email.send(fail_silently=False)
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
# django_core/invitations/mixins.py
|
|
2
|
+
|
|
3
|
+
from django.contrib.auth import get_user_model
|
|
4
|
+
from django.contrib.auth.tokens import default_token_generator
|
|
5
|
+
from django.utils.encoding import force_bytes
|
|
6
|
+
from django.utils.http import urlsafe_base64_encode
|
|
7
|
+
|
|
8
|
+
from rest_framework import status
|
|
9
|
+
from rest_framework.decorators import action
|
|
10
|
+
from rest_framework.permissions import AllowAny, IsAuthenticated
|
|
11
|
+
from rest_framework.response import Response
|
|
12
|
+
|
|
13
|
+
from .serializers import InviteUserSerializer
|
|
14
|
+
from .emails import send_invite_or_reset_email
|
|
15
|
+
|
|
16
|
+
User = get_user_model()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class InviteActionsMixin:
|
|
20
|
+
"""
|
|
21
|
+
Mixin für dein UserViewSet:
|
|
22
|
+
|
|
23
|
+
- POST /api/users/invite/ → Einladung (neu oder bestehend)
|
|
24
|
+
- POST /api/users/reset-request/ → Passwort vergessen (für bestehende User)
|
|
25
|
+
- GET /api/users/<pk>/invite-link/ → Invite-Link für Admins
|
|
26
|
+
|
|
27
|
+
Erwartet:
|
|
28
|
+
- user.profile.is_new (optional)
|
|
29
|
+
- user.profile.is_invited (optional)
|
|
30
|
+
- user.profile.role mit Wert "admin" für Admin-Erkennung
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
invite_serializer_class = InviteUserSerializer
|
|
34
|
+
|
|
35
|
+
def _get_invite_serializer(self, *args, **kwargs):
|
|
36
|
+
return self.invite_serializer_class(*args, **kwargs)
|
|
37
|
+
|
|
38
|
+
def _mark_invited_profile(self, user, *, created: bool) -> None:
|
|
39
|
+
profile = getattr(user, "profile", None)
|
|
40
|
+
if not profile:
|
|
41
|
+
return
|
|
42
|
+
|
|
43
|
+
if created and hasattr(profile, "is_new"):
|
|
44
|
+
profile.is_new = True
|
|
45
|
+
|
|
46
|
+
if hasattr(profile, "is_invited"):
|
|
47
|
+
profile.is_invited = True
|
|
48
|
+
|
|
49
|
+
profile.save()
|
|
50
|
+
|
|
51
|
+
def _is_admin_or_superuser(self, user) -> bool:
|
|
52
|
+
if not user or not user.is_authenticated:
|
|
53
|
+
return False
|
|
54
|
+
if user.is_superuser:
|
|
55
|
+
return True
|
|
56
|
+
profile = getattr(user, "profile", None)
|
|
57
|
+
return bool(profile and getattr(profile, "role", None) == "admin")
|
|
58
|
+
|
|
59
|
+
def _build_frontend_url(self, request, user, *, is_new_user: bool) -> str:
|
|
60
|
+
"""
|
|
61
|
+
Baut den Link, der im E-Mail landet,
|
|
62
|
+
z.B. https://template.bigler-consult.ch/invite/<uid>/<token>/
|
|
63
|
+
oder https://template.bigler-consult.ch/reset/<uid>/<token>/
|
|
64
|
+
"""
|
|
65
|
+
token = default_token_generator.make_token(user)
|
|
66
|
+
uid = urlsafe_base64_encode(force_bytes(user.pk))
|
|
67
|
+
|
|
68
|
+
base = request.build_absolute_uri("/").rstrip("/") # https://domain.tld
|
|
69
|
+
if is_new_user:
|
|
70
|
+
path = f"/invite/{uid}/{token}/"
|
|
71
|
+
else:
|
|
72
|
+
path = f"/reset/{uid}/{token}/"
|
|
73
|
+
|
|
74
|
+
return f"{base}{path}"
|
|
75
|
+
|
|
76
|
+
# ------------------------------------------------------------------ #
|
|
77
|
+
# 1) Öffentlicher/Admin-Invite
|
|
78
|
+
# ------------------------------------------------------------------ #
|
|
79
|
+
@action(
|
|
80
|
+
detail=False,
|
|
81
|
+
methods=["post"],
|
|
82
|
+
url_path="invite",
|
|
83
|
+
authentication_classes=[],
|
|
84
|
+
permission_classes=[AllowAny],
|
|
85
|
+
)
|
|
86
|
+
def invite(self, request):
|
|
87
|
+
"""
|
|
88
|
+
- nicht eingeloggt: User lädt sich selbst ein (Email → Link setzen Passwort)
|
|
89
|
+
- eingeloggt + Admin: Admin lädt beliebige Email-Adresse ein
|
|
90
|
+
"""
|
|
91
|
+
serializer = self._get_invite_serializer(data=request.data)
|
|
92
|
+
serializer.is_valid(raise_exception=True)
|
|
93
|
+
email = serializer.validated_data["email"]
|
|
94
|
+
|
|
95
|
+
# Admin-Check bei eingeloggtem User
|
|
96
|
+
if request.user and request.user.is_authenticated:
|
|
97
|
+
if not self._is_admin_or_superuser(request.user):
|
|
98
|
+
return Response(
|
|
99
|
+
{"detail": "Permission denied."},
|
|
100
|
+
status=status.HTTP_403_FORBIDDEN,
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
user, created = User.objects.get_or_create(
|
|
104
|
+
email=email,
|
|
105
|
+
defaults={"username": email},
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
self._mark_invited_profile(user, created=created)
|
|
109
|
+
url = self._build_frontend_url(request, user, is_new_user=True)
|
|
110
|
+
|
|
111
|
+
# WICHTIG: kein "request=" mehr, nur user/url/is_new_user
|
|
112
|
+
send_invite_or_reset_email(
|
|
113
|
+
user=user,
|
|
114
|
+
url=url,
|
|
115
|
+
is_new_user=True,
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
return Response(
|
|
119
|
+
{"detail": f"Invitation sent to {email}", "created": created},
|
|
120
|
+
status=status.HTTP_201_CREATED,
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
# ------------------------------------------------------------------ #
|
|
124
|
+
# 2) Passwort-vergessen-Flow
|
|
125
|
+
# ------------------------------------------------------------------ #
|
|
126
|
+
@action(
|
|
127
|
+
detail=False,
|
|
128
|
+
methods=["post"],
|
|
129
|
+
url_path="reset-request",
|
|
130
|
+
permission_classes=[AllowAny],
|
|
131
|
+
authentication_classes=[],
|
|
132
|
+
)
|
|
133
|
+
def reset_request(self, request):
|
|
134
|
+
"""
|
|
135
|
+
Vergisst Passwort: schickt Reset-Link an bestehende Email.
|
|
136
|
+
Nutzt denselben Template-Mechanismus wie Invite,
|
|
137
|
+
aber mit is_new_user=False → andere Texte/Templates möglich.
|
|
138
|
+
"""
|
|
139
|
+
email = request.data.get("email")
|
|
140
|
+
if not email:
|
|
141
|
+
return Response(
|
|
142
|
+
{"detail": "Email not provided."},
|
|
143
|
+
status=status.HTTP_400_BAD_REQUEST,
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
try:
|
|
147
|
+
user = User.objects.get(email=email)
|
|
148
|
+
except User.DoesNotExist:
|
|
149
|
+
# Entweder 200 zurückgeben (nicht leaken) oder wie bisher 400:
|
|
150
|
+
return Response(
|
|
151
|
+
{"detail": "User not found."},
|
|
152
|
+
status=status.HTTP_400_BAD_REQUEST,
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
url = self._build_frontend_url(request, user, is_new_user=False)
|
|
156
|
+
|
|
157
|
+
send_invite_or_reset_email(
|
|
158
|
+
user=user,
|
|
159
|
+
url=url,
|
|
160
|
+
is_new_user=False,
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
return Response(
|
|
164
|
+
{"detail": f"Ein Link wurde an deine Mail-Adresse {email} geschickt"},
|
|
165
|
+
status=status.HTTP_200_OK,
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
# ------------------------------------------------------------------ #
|
|
169
|
+
# 3) Invite-Link für Admins (z.B. im Admin-UI anzeigen)
|
|
170
|
+
# ------------------------------------------------------------------ #
|
|
171
|
+
@action(
|
|
172
|
+
detail=True,
|
|
173
|
+
methods=["get"],
|
|
174
|
+
url_path="invite-link",
|
|
175
|
+
permission_classes=[IsAuthenticated],
|
|
176
|
+
)
|
|
177
|
+
def invite_link(self, request, pk=None):
|
|
178
|
+
user = self.get_object()
|
|
179
|
+
|
|
180
|
+
if not self._is_admin_or_superuser(request.user):
|
|
181
|
+
return Response(
|
|
182
|
+
{"detail": "Permission denied."},
|
|
183
|
+
status=status.HTTP_403_FORBIDDEN,
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
url = self._build_frontend_url(request, user, is_new_user=True)
|
|
187
|
+
|
|
188
|
+
return Response({"invite_link": url}, status=status.HTTP_200_OK)
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# webapp_management/invitations/views.py
|
|
2
|
+
|
|
3
|
+
from django.contrib.auth import get_user_model
|
|
4
|
+
from django.contrib.auth.tokens import default_token_generator
|
|
5
|
+
from django.utils.encoding import force_str
|
|
6
|
+
from django.utils.http import urlsafe_base64_decode
|
|
7
|
+
|
|
8
|
+
from rest_framework.views import APIView
|
|
9
|
+
from rest_framework.permissions import AllowAny
|
|
10
|
+
from rest_framework.response import Response
|
|
11
|
+
from rest_framework import status
|
|
12
|
+
|
|
13
|
+
User = get_user_model()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class NonAuthenticatedPasswordResetView(APIView):
|
|
17
|
+
"""
|
|
18
|
+
Finale Schritt des Reset-/Invite-Flows:
|
|
19
|
+
- GET → prüft, ob UID+Token gültig sind
|
|
20
|
+
- POST → setzt neues Passwort (ohne eingeloggt zu sein)
|
|
21
|
+
|
|
22
|
+
Typischer Ablauf im Frontend:
|
|
23
|
+
1. User klickt Link aus Mail (z.B. /reset/<uid>/<token>/ oder /invite/<uid>/<token>/)
|
|
24
|
+
2. SPA liest uid & token aus der URL
|
|
25
|
+
3. SPA ruft:
|
|
26
|
+
- GET /api/.../reset/<uid>/<token>/ → "valid?" check
|
|
27
|
+
- POST /api/.../reset/<uid>/<token>/ → neues Passwort setzen
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
permission_classes = [AllowAny]
|
|
31
|
+
|
|
32
|
+
# Texte kannst du in einem Subclass pro Projekt überschreiben
|
|
33
|
+
link_valid_message = "Reset link is valid."
|
|
34
|
+
link_invalid_message = "Reset link is invalid."
|
|
35
|
+
missing_password_message = "Neues Passwort wurde nicht angegeben."
|
|
36
|
+
invalid_link_message = "Reset link is invalid."
|
|
37
|
+
success_message = "Passwort erfolgreich geändert."
|
|
38
|
+
|
|
39
|
+
def get_user_from_uid(self, uidb64):
|
|
40
|
+
try:
|
|
41
|
+
uid = force_str(urlsafe_base64_decode(uidb64))
|
|
42
|
+
return User.objects.get(pk=uid)
|
|
43
|
+
except Exception:
|
|
44
|
+
return None
|
|
45
|
+
|
|
46
|
+
def get(self, request, uidb64, token, *args, **kwargs):
|
|
47
|
+
"""
|
|
48
|
+
Prüft, ob der Link (UID+Token) noch gültig ist.
|
|
49
|
+
"""
|
|
50
|
+
user = self.get_user_from_uid(uidb64)
|
|
51
|
+
if user and default_token_generator.check_token(user, token):
|
|
52
|
+
return Response({"detail": self.link_valid_message})
|
|
53
|
+
return Response(
|
|
54
|
+
{"detail": self.link_invalid_message},
|
|
55
|
+
status=status.HTTP_400_BAD_REQUEST,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
def post(self, request, uidb64, token, *args, **kwargs):
|
|
59
|
+
"""
|
|
60
|
+
Setzt das neue Passwort für den User mit UID+Token.
|
|
61
|
+
"""
|
|
62
|
+
new_pw = request.data.get("new_password")
|
|
63
|
+
if not new_pw:
|
|
64
|
+
return Response(
|
|
65
|
+
{"detail": self.missing_password_message},
|
|
66
|
+
status=status.HTTP_400_BAD_REQUEST,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
user = self.get_user_from_uid(uidb64)
|
|
70
|
+
if not user:
|
|
71
|
+
return Response(
|
|
72
|
+
{"detail": self.invalid_link_message},
|
|
73
|
+
status=status.HTTP_400_BAD_REQUEST,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
if not default_token_generator.check_token(user, token):
|
|
77
|
+
return Response(
|
|
78
|
+
{"detail": self.link_invalid_message},
|
|
79
|
+
status=status.HTTP_400_BAD_REQUEST,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
user.set_password(new_pw)
|
|
83
|
+
if hasattr(user, "is_active"):
|
|
84
|
+
user.is_active = True
|
|
85
|
+
user.save()
|
|
86
|
+
|
|
87
|
+
return Response({"detail": self.success_message})
|
|
File without changes
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
import argparse
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
import yaml # pip install PyYAML
|
|
6
|
+
|
|
7
|
+
def get_secret(key, default=None, required=False):
|
|
8
|
+
"""Retrieves a secret from env vars (CI) or returns default."""
|
|
9
|
+
val = os.environ.get(key, default)
|
|
10
|
+
if required and not val:
|
|
11
|
+
print(f"❌ Error: Secret '{key}' is required but not set in environment.")
|
|
12
|
+
sys.exit(1)
|
|
13
|
+
return val
|
|
14
|
+
|
|
15
|
+
def write_env_file(path, lines):
|
|
16
|
+
"""Helper to write the list of lines to the .env file."""
|
|
17
|
+
try:
|
|
18
|
+
with open(path, "w") as f:
|
|
19
|
+
f.write("\n".join(lines))
|
|
20
|
+
f.write("\n")
|
|
21
|
+
print(f"✅ Successfully wrote {path}")
|
|
22
|
+
except Exception as e:
|
|
23
|
+
print(f"❌ Error writing file: {e}")
|
|
24
|
+
sys.exit(1)
|
|
25
|
+
|
|
26
|
+
def generate_env(env_name, config_path="project.yaml", output_path=".env"):
|
|
27
|
+
print(f"⚙️ Generating .env for environment: {env_name}")
|
|
28
|
+
|
|
29
|
+
if not os.path.exists(config_path):
|
|
30
|
+
print(f"❌ Error: Config file '{config_path}' not found.")
|
|
31
|
+
sys.exit(1)
|
|
32
|
+
|
|
33
|
+
with open(config_path, "r") as f:
|
|
34
|
+
config = yaml.safe_load(f)
|
|
35
|
+
|
|
36
|
+
project_type = config.get("project_type", "django")
|
|
37
|
+
|
|
38
|
+
# 1. Validate Environment exists in YAML
|
|
39
|
+
if env_name not in config.get("environments", {}):
|
|
40
|
+
if env_name == "local" and project_type == "infrastructure":
|
|
41
|
+
print("ℹ️ Infrastructure app does not require local .env generation. Exiting.")
|
|
42
|
+
sys.exit(0)
|
|
43
|
+
|
|
44
|
+
print(f"❌ Error: Environment '{env_name}' not found in {config_path}")
|
|
45
|
+
sys.exit(1)
|
|
46
|
+
|
|
47
|
+
env_config = config["environments"][env_name]
|
|
48
|
+
env_overrides = env_config.get("env_overrides", {})
|
|
49
|
+
env_content = []
|
|
50
|
+
|
|
51
|
+
# ==========================================
|
|
52
|
+
# MODE A: INFRASTRUCTURE
|
|
53
|
+
# ==========================================
|
|
54
|
+
if project_type == "infrastructure":
|
|
55
|
+
print("🏗️ Generating Infrastructure .env")
|
|
56
|
+
# [Infrastructure logic omitted for brevity - logic remains identical]
|
|
57
|
+
domain_map = env_config.get("domains", {})
|
|
58
|
+
for var_name, domain in domain_map.items():
|
|
59
|
+
env_content.append(f"{var_name}={domain}")
|
|
60
|
+
|
|
61
|
+
infra_secrets = ["TRAEFIK_DASHBOARD_AUTH", "ACME_EMAIL", "WG_SERVERURL", "WG_PEERS"]
|
|
62
|
+
for secret in infra_secrets:
|
|
63
|
+
if secret in env_overrides:
|
|
64
|
+
val = env_overrides[secret]
|
|
65
|
+
else:
|
|
66
|
+
val = get_secret(secret, required=False)
|
|
67
|
+
|
|
68
|
+
if val:
|
|
69
|
+
if secret == "TRAEFIK_DASHBOARD_AUTH": val = val.replace("$", "$$")
|
|
70
|
+
env_content.append(f"{secret}={val}")
|
|
71
|
+
|
|
72
|
+
env_content.append(f"CONTAINER_NAME_PREFIX={config.get('container_prefix', 'infra')}")
|
|
73
|
+
write_env_file(output_path, env_content)
|
|
74
|
+
return
|
|
75
|
+
|
|
76
|
+
# ==========================================
|
|
77
|
+
# MODE B: STANDARD DJANGO APP
|
|
78
|
+
# ==========================================
|
|
79
|
+
domains = env_config.get("domains", [])
|
|
80
|
+
use_traefik = env_config.get("use_traefik", False)
|
|
81
|
+
is_local = (env_name == "local")
|
|
82
|
+
local_defaults = env_config.get("defaults", {})
|
|
83
|
+
|
|
84
|
+
def resolve(key, required_in_prod=True):
|
|
85
|
+
if key in env_overrides: return env_overrides[key]
|
|
86
|
+
if is_local: return local_defaults.get(key, "")
|
|
87
|
+
return get_secret(key, required=required_in_prod)
|
|
88
|
+
|
|
89
|
+
# --- Database ---
|
|
90
|
+
env_content.append(f"# --- Database ---")
|
|
91
|
+
env_content.append(f"DB_USER={resolve('DB_USER')}")
|
|
92
|
+
env_content.append(f"DB_PASSWORD={resolve('DB_PASSWORD')}")
|
|
93
|
+
env_content.append(f"DB_NAME={resolve('DB_NAME')}")
|
|
94
|
+
env_content.append(f"DB_HOST={resolve('DB_HOST')}")
|
|
95
|
+
env_content.append(f"DB_PORT={resolve('DB_PORT')}")
|
|
96
|
+
|
|
97
|
+
# --- Django ---
|
|
98
|
+
env_content.append(f"\n# --- Django ---")
|
|
99
|
+
env_content.append(f"DJANGO_SECRET_KEY={resolve('DJANGO_SECRET_KEY', required_in_prod=True)}")
|
|
100
|
+
env_content.append(f"PIP_TOKEN={resolve('PIP_TOKEN', required_in_prod=True)}")
|
|
101
|
+
env_content.append(f"ENV_TYPE={env_name}")
|
|
102
|
+
debug_val = resolve('DEBUG', required_in_prod=False)
|
|
103
|
+
env_content.append(f"DEBUG={debug_val or 'False'}")
|
|
104
|
+
|
|
105
|
+
# --- Mail ---
|
|
106
|
+
env_content.append(f"\n# --- Mail ---")
|
|
107
|
+
if is_local:
|
|
108
|
+
env_content.append("EMAIL_BACKEND=django.core.mail.backends.console.EmailBackend")
|
|
109
|
+
else:
|
|
110
|
+
env_content.append(f"EMAIL_HOST={resolve('EMAIL_HOST', required_in_prod=False)}")
|
|
111
|
+
env_content.append(f"EMAIL_PORT={resolve('EMAIL_PORT', required_in_prod=False)}")
|
|
112
|
+
env_content.append(f"EMAIL_USE_TLS={resolve('EMAIL_USE_TLS', required_in_prod=False)}")
|
|
113
|
+
env_content.append(f"EMAIL_USER={resolve('EMAIL_USER', required_in_prod=False)}")
|
|
114
|
+
env_content.append(f"EMAIL_PASSWORD={resolve('EMAIL_PASSWORD', required_in_prod=False)}")
|
|
115
|
+
env_content.append(f"DEFAULT_FROM_EMAIL={resolve('EMAIL_USER', required_in_prod=False)}")
|
|
116
|
+
|
|
117
|
+
ex_key = resolve("EXCHANGERATE_HOST_KEY", required_in_prod=False)
|
|
118
|
+
if ex_key:
|
|
119
|
+
env_content.append(f"EXCHANGERATE_HOST_KEY={ex_key}")
|
|
120
|
+
|
|
121
|
+
# --- Infrastructure ---
|
|
122
|
+
env_content.append(f"\n# --- Infrastructure ---")
|
|
123
|
+
|
|
124
|
+
# Fetch the base prefix (now "jg_ferien")
|
|
125
|
+
base_prefix = config.get("container_prefix", "app")
|
|
126
|
+
|
|
127
|
+
# Logic: Construct the full prefix based on environment
|
|
128
|
+
if env_name == "staging":
|
|
129
|
+
ctr_prefix = f"{base_prefix}_stage"
|
|
130
|
+
elif env_name == "production":
|
|
131
|
+
ctr_prefix = f"{base_prefix}_prod"
|
|
132
|
+
else:
|
|
133
|
+
# Fallback for local or other environments
|
|
134
|
+
ctr_prefix = base_prefix
|
|
135
|
+
|
|
136
|
+
env_content.append(f"CONTAINER_NAME_PREFIX={ctr_prefix}")
|
|
137
|
+
env_content.append(f"ROUTER_NAME={config.get('project_name')}-{env_name}")
|
|
138
|
+
|
|
139
|
+
# --- VOLUMES (New Section) ---
|
|
140
|
+
vol_config = env_config.get("volumes", {})
|
|
141
|
+
|
|
142
|
+
def get_vol_name(key, default_name):
|
|
143
|
+
val = vol_config.get(key)
|
|
144
|
+
# Handle dict format (e.g., {external: true, name: 'foo'})
|
|
145
|
+
if isinstance(val, dict):
|
|
146
|
+
return val.get("name", default_name)
|
|
147
|
+
# Handle simple string format or None
|
|
148
|
+
return val if val else default_name
|
|
149
|
+
|
|
150
|
+
db_vol = get_vol_name("postgres_data", f"{ctr_prefix}_postgres_data")
|
|
151
|
+
media_vol = get_vol_name("media_volume", f"{ctr_prefix}_media_volume")
|
|
152
|
+
excel_vol = get_vol_name("excel_volume", f"{ctr_prefix}_excel_volume")
|
|
153
|
+
|
|
154
|
+
env_content.append(f"DB_VOLUME_NAME={db_vol}")
|
|
155
|
+
env_content.append(f"MEDIA_VOLUME_NAME={media_vol}")
|
|
156
|
+
env_content.append(f"EXCEL_VOLUME_NAME={excel_vol}")
|
|
157
|
+
|
|
158
|
+
# --- Network ---
|
|
159
|
+
main_domain = domains[0] if domains else "localhost"
|
|
160
|
+
env_content.append(f"DJANGO_ALLOWED_HOSTS={','.join(domains)}")
|
|
161
|
+
protocol = "https" if use_traefik else "http"
|
|
162
|
+
csrf_urls = [f"{protocol}://{d}" for d in domains]
|
|
163
|
+
if is_local:
|
|
164
|
+
csrf_urls.extend(["http://localhost:3000", "http://127.0.0.1:3000"])
|
|
165
|
+
|
|
166
|
+
env_content.append(f"CSRF_TRUSTED_URLS={','.join(csrf_urls)}")
|
|
167
|
+
env_content.append(f"PUBLIC_ORIGIN={protocol}://{main_domain}")
|
|
168
|
+
|
|
169
|
+
if use_traefik:
|
|
170
|
+
rules = [f"Host(`{d}`)" for d in domains]
|
|
171
|
+
env_content.append(f"TRAEFIK_ROUTER_RULE={' || '.join(rules)}")
|
|
172
|
+
else:
|
|
173
|
+
env_content.append("TRAEFIK_ROUTER_RULE=Host(`localhost`)")
|
|
174
|
+
|
|
175
|
+
root_mod = config.get("root_module", "project_template_app")
|
|
176
|
+
env_content.append(f"DJANGO_ROOT_MODULE={root_mod}")
|
|
177
|
+
|
|
178
|
+
# --- Auth / Social Secrets ---
|
|
179
|
+
env_content.append(f"\n# --- Social Auth ---")
|
|
180
|
+
# Google
|
|
181
|
+
env_content.append(f"GOOGLE_CLIENT_ID={resolve('GOOGLE_CLIENT_ID', required_in_prod=False)}")
|
|
182
|
+
env_content.append(f"GOOGLE_SECRET={resolve('GOOGLE_SECRET', required_in_prod=False)}")
|
|
183
|
+
# Microsoft
|
|
184
|
+
env_content.append(f"MICROSOFT_CLIENT_ID={resolve('MICROSOFT_CLIENT_ID', required_in_prod=False)}")
|
|
185
|
+
env_content.append(f"MICROSOFT_SECRET={resolve('MICROSOFT_SECRET', required_in_prod=False)}")
|
|
186
|
+
env_content.append(f"MICROSOFT_TENANT_ID={resolve('MICROSOFT_TENANT_ID', required_in_prod=False)}")
|
|
187
|
+
|
|
188
|
+
write_env_file(output_path, env_content)
|
|
189
|
+
|
|
190
|
+
def main():
|
|
191
|
+
parser = argparse.ArgumentParser()
|
|
192
|
+
parser.add_argument("--env", required=True, help="Environment (production, staging, local)")
|
|
193
|
+
# Default output ist nun das aktuelle Verzeichnis, wo der Befehl ausgeführt wird
|
|
194
|
+
parser.add_argument("--output", default=".env", help="Output file path")
|
|
195
|
+
# Optional: Config-Pfad konfigurierbar machen, default auf aktuelles Dir
|
|
196
|
+
parser.add_argument("--config", default="project.yaml", help="Path to project.yaml")
|
|
197
|
+
|
|
198
|
+
args = parser.parse_args()
|
|
199
|
+
|
|
200
|
+
# Übergib args.config an generate_env
|
|
201
|
+
generate_env(args.env, config_path=args.config, output_path=args.output)
|
|
202
|
+
|
|
203
|
+
if __name__ == "__main__":
|
|
204
|
+
main()
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: django-core-micha
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Core utilities and settings for Django Apps
|
|
5
|
+
Author-email: Micha Bigler <micha.bigler2@gmail.com>
|
|
6
|
+
Project-URL: Homepage, https://github.com/MichaBigler/webapp-management
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Requires-Dist: Django>=4.0
|
|
9
|
+
Requires-Dist: PyYAML
|
|
10
|
+
Requires-Dist: psycopg2-binary
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
src/django-core-micha/__init__.py
|
|
4
|
+
src/django-core-micha/auth/__init__.py
|
|
5
|
+
src/django-core-micha/auth/adapters.py
|
|
6
|
+
src/django-core-micha/auth/apps.py
|
|
7
|
+
src/django-core-micha/auth/models.py
|
|
8
|
+
src/django-core-micha/auth/signals.py
|
|
9
|
+
src/django-core-micha/invitations/__init__.py
|
|
10
|
+
src/django-core-micha/invitations/apps.py
|
|
11
|
+
src/django-core-micha/invitations/emails.py
|
|
12
|
+
src/django-core-micha/invitations/mixins.py
|
|
13
|
+
src/django-core-micha/invitations/serializers.py
|
|
14
|
+
src/django-core-micha/invitations/views.py
|
|
15
|
+
src/django-core-micha/scripts/__init__.py
|
|
16
|
+
src/django-core-micha/scripts/generate_env.py
|
|
17
|
+
src/django-core-micha/settings/__init__.py
|
|
18
|
+
src/django-core-micha/settings/defaults.py
|
|
19
|
+
src/django_core_micha.egg-info/PKG-INFO
|
|
20
|
+
src/django_core_micha.egg-info/SOURCES.txt
|
|
21
|
+
src/django_core_micha.egg-info/dependency_links.txt
|
|
22
|
+
src/django_core_micha.egg-info/entry_points.txt
|
|
23
|
+
src/django_core_micha.egg-info/requires.txt
|
|
24
|
+
src/django_core_micha.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|