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.
Files changed (26) hide show
  1. django_core_micha-0.1.0/PKG-INFO +10 -0
  2. django_core_micha-0.1.0/README.md +0 -0
  3. django_core_micha-0.1.0/pyproject.toml +25 -0
  4. django_core_micha-0.1.0/setup.cfg +4 -0
  5. django_core_micha-0.1.0/src/django-core-micha/__init__.py +0 -0
  6. django_core_micha-0.1.0/src/django-core-micha/auth/__init__.py +0 -0
  7. django_core_micha-0.1.0/src/django-core-micha/auth/adapters.py +9 -0
  8. django_core_micha-0.1.0/src/django-core-micha/auth/apps.py +8 -0
  9. django_core_micha-0.1.0/src/django-core-micha/auth/models.py +18 -0
  10. django_core_micha-0.1.0/src/django-core-micha/auth/signals.py +39 -0
  11. django_core_micha-0.1.0/src/django-core-micha/invitations/__init__.py +0 -0
  12. django_core_micha-0.1.0/src/django-core-micha/invitations/apps.py +7 -0
  13. django_core_micha-0.1.0/src/django-core-micha/invitations/emails.py +60 -0
  14. django_core_micha-0.1.0/src/django-core-micha/invitations/mixins.py +188 -0
  15. django_core_micha-0.1.0/src/django-core-micha/invitations/serializers.py +6 -0
  16. django_core_micha-0.1.0/src/django-core-micha/invitations/views.py +87 -0
  17. django_core_micha-0.1.0/src/django-core-micha/scripts/__init__.py +0 -0
  18. django_core_micha-0.1.0/src/django-core-micha/scripts/generate_env.py +204 -0
  19. django_core_micha-0.1.0/src/django-core-micha/settings/__init__.py +0 -0
  20. django_core_micha-0.1.0/src/django-core-micha/settings/defaults.py +0 -0
  21. django_core_micha-0.1.0/src/django_core_micha.egg-info/PKG-INFO +10 -0
  22. django_core_micha-0.1.0/src/django_core_micha.egg-info/SOURCES.txt +24 -0
  23. django_core_micha-0.1.0/src/django_core_micha.egg-info/dependency_links.txt +1 -0
  24. django_core_micha-0.1.0/src/django_core_micha.egg-info/entry_points.txt +2 -0
  25. django_core_micha-0.1.0/src/django_core_micha.egg-info/requires.txt +3 -0
  26. 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"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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,8 @@
1
+ from django.apps import AppConfig
2
+
3
+ class CoreAuthConfig(AppConfig):
4
+ name = 'django_core.auth'
5
+ label = 'core_auth'
6
+
7
+ def ready(self):
8
+ import django_core.auth.signals
@@ -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
@@ -0,0 +1,7 @@
1
+ from django.apps import AppConfig
2
+
3
+
4
+ class InvitationsCoreConfig(AppConfig):
5
+ name = "django_core.invitations"
6
+ label = "django_core_invitations"
7
+ verbose_name = "Core Invitations"
@@ -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,6 @@
1
+ from rest_framework import serializers
2
+
3
+
4
+ class InviteUserSerializer(serializers.Serializer):
5
+ """Simple serializer holding the invite target email address."""
6
+ email = serializers.EmailField()
@@ -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})
@@ -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()
@@ -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,2 @@
1
+ [console_scripts]
2
+ generate-env = django_core.scripts.generate_env:main
@@ -0,0 +1,3 @@
1
+ Django>=4.0
2
+ PyYAML
3
+ psycopg2-binary
@@ -0,0 +1,2 @@
1
+ django-core-micha
2
+ ui_core