commit db213d3228d1bffa20312a17a4c363d9625d5b33 Author: rizwanisready Date: Thu Feb 29 13:25:50 2024 +0530 wrong commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ad9ce3a --- /dev/null +++ b/.gitignore @@ -0,0 +1,148 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal +media +logs + + + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + + + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + + + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +nifty11appvenv/ +ENV/ +env.bak/ +venv.bak/ + + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ +stripe/ + +# Cython debug symbols +# cython_debug/ +# Footer +# © 2022 GitHub, Inc. +# Footer navigation +# Terms +# Privacy +# Security +# Status +# Docs +# Contact GitHub +# Pricing +# # API +# Training +# Blog +# About diff --git a/accounts/__init__.py b/accounts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/accounts/admin.py b/accounts/admin.py new file mode 100644 index 0000000..d2411c4 --- /dev/null +++ b/accounts/admin.py @@ -0,0 +1,93 @@ +from django.contrib import admin +from django.contrib.auth.admin import UserAdmin +from . import models + +# Register your models here. + + +# class CustomIAmPrincipalAdmin(UserAdmin): +# list_display = ( +# "id", +# "principal_type", +# "principal_source", +# "gender", +# "date_of_birth", +# "first_name", +# "last_name", +# "email", +# "phone_no", +# "is_staff", +# "is_superuser", +# "is_active", +# "address_line1", +# "address_line1", +# "city", +# "state", +# "country", +# "post_code", +# "profile_photo", +# "deleted", +# ) + +# list_filter = ("principal_type",) + +# search_fields = ("email", "phone_no") + +# fieldsets = ( +# (None, {"fields": ("email", "password")}), +# ( +# "Personal info", +# { +# "fields": ( +# "first_name", +# "last_name", +# "gender", +# "date_of_birth", +# "phone_number", +# "address_line_one", +# "address_line_two", +# "city", +# "state", +# "country", +# "post_code", +# "profile_photo", +# ) +# }, +# ), +# ( +# "Permissions", +# {"fields": ("is_active", "is_staff", "is_superuser", "deleted")}, +# ), +# ("Important dates", {"fields": ("last_login",)}), +# ) + +# add_fieldsets = ( +# ( +# None, +# { +# "classes": ("wide",), +# "fields": ("email", "password1", "password2"), +# }, +# ), +# ) + +# ordering = ["email"] # Specify the field to be used for ordering + + +# Now register the new UserModelAdmin... + +admin.site.register(models.IAmPrincipal) + +admin.site.register(models.IAmPrincipalType) +# admin.site.register(IAmPrincipal) +admin.site.register(models.IAmPrincipalSource) +admin.site.register(models.IAmPrincipalGroup) +admin.site.register(models.IAmAppResource) +admin.site.register(models.IAmRole) +admin.site.register(models.IAmAppAction) +admin.site.register(models.IAmPrincipalGroupLink) +admin.site.register(models.IAmPrincipalOtp) +admin.site.register(models.IAmPrincipalBiometric) +admin.site.register(models.IAmAppResourceActionLink) +admin.site.register(models.IAmPricipalGroupRoleLink) +admin.site.register(models.IAmRoleAppResourceActionLink) diff --git a/accounts/api/authenticate.py b/accounts/api/authenticate.py new file mode 100644 index 0000000..74097bc --- /dev/null +++ b/accounts/api/authenticate.py @@ -0,0 +1,42 @@ +from django.conf import settings +from django.contrib.auth.hashers import check_password +from rest_framework import exceptions +from rest_framework.authentication import CSRFCheck +from rest_framework_simplejwt.authentication import JWTAuthentication + +from accounts.models import IAmPrincipalOtp, IAmPrincipal +from nifty11_project import constants +from nifty11_project.utils import ApiResponse + + +def enforce_csrf(request): + """ + Enforce CSRF validation for session based authentication. + """ + + def dummy_get_response(request): # pragma: no cover + return None + + check = CSRFCheck(dummy_get_response) + # populates request.META['CSRF_COOKIE'], which is used in process_view() + check.process_request(request) + reason = check.process_view(request, None, (), {}) + if reason: + # CSRF failed, bail with explicit error message + raise exceptions.PermissionDenied("CSRF Failed: %s" % reason) + + +class CustomAuthentication(JWTAuthentication): + def authenticate(self, request): + header = self.get_header(request) + + if header is None: + raw_token = request.COOKIES.get(settings.SIMPLE_JWT["AUTH_COOKIE"]) or None + else: + raw_token = self.get_raw_token(header) + if raw_token is None: + return None + + validated_token = self.get_validated_token(raw_token) + enforce_csrf(request) + return self.get_user(validated_token), validated_token diff --git a/accounts/api/serializers.py b/accounts/api/serializers.py new file mode 100644 index 0000000..d4a1642 --- /dev/null +++ b/accounts/api/serializers.py @@ -0,0 +1,262 @@ +import re + +from django.contrib.auth.hashers import make_password +from rest_framework import serializers + +from accounts.models import ( + IAmPrincipal, + IAmPrincipalType, + # IAmPrincipalKYCDetails, +) +from manage_referrals.models import ( + ReferralCode, + ReferralRecord, +) +from goodtimes import constants, date_utils + + +def clean_phone(number: str): + """Validates number starts with +91 or 0, then 10 digits""" + number_pattern = re.compile(r"^(?:\+91|0)\d{10}$") + result = number_pattern.match(number) + if not result: + raise serializers.ValidationError({"phone_no": constants.PHONE_NUMBER_INVALID}) + if number.startswith("0"): + return "+91" + number[1:] + return number + + +# class PhoneSerializer(serializers.Serializer): +# phone_no = serializers.CharField(max_length=15, required=True) + +# def validate(self, attrs): +# phone_no = attrs.get("phone_no") +# cleaned_number = clean_phone(phone_no) +# attrs["phone_no"] = cleaned_number +# return super().validate(attrs) + + +class EmailSerializer(serializers.Serializer): + email = serializers.EmailField( + required=True, + ) + principal_type = serializers.CharField(required=True, write_only=True) + + def validate_principal_type_name(self, value): + """ + Check that the principal_type_name corresponds to a valid IAmPrincipalType. + """ + if not IAmPrincipalType.objects.filter(name=value).exists(): + raise serializers.ValidationError("Invalid principal type name.") + return value + + +class BasePasswordSerializer(serializers.Serializer): + confirm_password = serializers.CharField(max_length=20, write_only=True) + + def validate(self, attrs): + password = attrs.get("password") + confirm_password = attrs.pop("confirm_password", None) + + if password != confirm_password: + raise serializers.ValidationError( + {"confirm_password": "Password do not match"} + ) + + return super().validate(attrs) + + def update(self, instance, validated_data): + new_password = validated_data.get("password") + if new_password: + instance.password = make_password(new_password) + # instance.set_password(new_password) + instance.save() + return instance + + +class RegistrationSerializer(BasePasswordSerializer, serializers.ModelSerializer): + # email = serializers.EmailField( + # required=True, + # ) + class Meta: + model = IAmPrincipal + fields = ["first_name", "last_name", "email", "password", "confirm_password"] + extra_kwargs = { + "first_name": {"required": True}, + "last_name": {"required": True}, + "email": {"required": True}, + } + + # def validate(self, attrs): + # email = attrs.get("email") + + # # Check if the email is already associated with any user + # user_with_email = IAmPrincipal.objects.filter(email=email).first() + + # if user_with_email: + # raise serializers.ValidationError({"email": [constants.EMAIL_EXISTS]}) + # # # Check if the user has a different phone number + # # if user_with_email.phone_no != phone_no: + # # raise serializers.ValidationError({"email": [constants.EMAIL_EXISTS]}) + + # return attrs + + def update(self, instance, validated_data): + # update prinicpal instance fiedls based on the validation data + instance.first_name = validated_data.get("first_name", instance.first_name) + instance.last_name = validated_data.get("last_name", instance.last_name) + instance.email = validated_data.get("email", instance.email) + instance.username = validated_data.get("email", instance.email) + # Set the new password (if provided) correctly using set_password method + if "password" in validated_data: + new_password = validated_data["password"] + print("new_password serializers: ", new_password) + instance.set_password( + new_password + ) # Correctly use set_password without assignment + + instance.save() + return instance + + +class RegistrationPasswordSerializer( + BasePasswordSerializer, serializers.ModelSerializer +): + class Meta: + model = IAmPrincipal + fields = ["email", "password", "confirm_password"] + + +class PasswordResetSerializer(BasePasswordSerializer, serializers.ModelSerializer): + class Meta: + model = IAmPrincipal + fields = ["password", "confirm_password"] + + +class ProfileSerializer(serializers.ModelSerializer): + profile_photo = serializers.ImageField(required=False) + phone_no = serializers.CharField(read_only=True) + email = serializers.CharField(read_only=True) + invite_count = serializers.SerializerMethodField(read_only=True) + + class Meta: + model = IAmPrincipal + fields = [ + "profile_photo", + "first_name", + "last_name", + "phone_no", + "email", + "invite_count", + ] + + def update(self, instance, validated_data): + instance.profile_photo = validated_data.get( + "profile_photo", instance.profile_photo + ) + instance.first_name = validated_data.get("first_name", instance.first_name) + instance.last_name = validated_data.get("last_name", instance.last_name) + return super().update(instance, validated_data) + + def get_invite_count(self, obj): + if obj: + principal_type = self.context.get( + "principal_type" + ) # Retrieve the principal_type from the context + return ReferralRecord.get_invite_count(obj, principal_type) + return 0 + + def get_image_url(self, obj, field_name, request): + image_field = getattr(obj, field_name) + if image_field: + return request.build_absolute_uri(image_field.url) + return "" + + def to_representation(self, instance): + data = super().to_representation(instance) + request = self.context.get("request") + data["profile_photo"] = self.get_image_url(instance, "profile_photo", request) + return data + + +# class PrincipalKYCDetailsSerializer(serializers.ModelSerializer): +# aadhar_front_image = serializers.ImageField(required=False) +# aadhar_back_image = serializers.ImageField(required=False) +# pan_image = serializers.ImageField(required=False) + +# class Meta: +# model = IAmPrincipalKYCDetails +# fields = [ +# "aadhar_front_image", +# "aadhar_back_image", +# "aadhar_number", +# "is_aadhar_verified", +# "pan_image", +# "pan_number", +# "is_pan_verified", +# "account_no", +# "bank_name", +# "branch_name", +# "ifsc_code", +# ] + +# def create(self, validated_data): +# # Extract and set the principal (user) from the request context +# validated_data["principal"] = self.context.get("request").user + +# return super(PrincipalKYCDetailsSerializer, self).create(validated_data) + +# def get_image_url(self, obj, field_name, request): +# image_field = getattr(obj, field_name) +# if image_field: +# return request.build_absolute_uri(image_field.url) +# return "" + +# def to_representation(self, instance): +# data = super().to_representation(instance) +# request = self.context.get("request") +# if request: +# data["aadhar_front_image"] = self.get_image_url( +# instance, "aadhar_front_image", request +# ) +# data["aadhar_back_image"] = self.get_image_url( +# instance, "aadhar_back_image", request +# ) +# data["pan_image"] = self.get_image_url(instance, "pan_image", request) +# return data + + +class ReferralCodeSerializer(serializers.ModelSerializer): + class Meta: + model = ReferralCode + fields = ["referral_code"] + + +class ReferralRecordSerializer(serializers.ModelSerializer): + name = serializers.SerializerMethodField() + join_at = serializers.SerializerMethodField() + + class Meta: + model = ReferralRecord + fields = ["name", "join_at"] + + def get_name(self, obj): + # Check if the referred_principal is set (not None) and get the full name + if obj.referred_principal: + return obj.referred_principal.get_full_name() + return None + + def get_join_at(self, obj): + return date_utils.format_date_to_string(obj.created_on) + + +# added 's' to differentiate with Email Serializer +class EmailSerializers(serializers.Serializer): + email = serializers.EmailField(required=True) + # otp = serializers.CharField(required=True) + + +class ProfilePhotoSerializer(serializers.ModelSerializer): + class Meta: + model = IAmPrincipal + fields = ("email", "profile_photo", "first_name") diff --git a/accounts/api/urls.py b/accounts/api/urls.py new file mode 100644 index 0000000..9ee6cec --- /dev/null +++ b/accounts/api/urls.py @@ -0,0 +1,35 @@ +from django.urls import path +from . import views +from rest_framework_simplejwt.views import ( + TokenRefreshView, +) + + +urlpatterns = [ + path('token/refresh/', TokenRefreshView.as_view(), name='token_refresh'), + path('signup/phone/', views.RegistrationEmailView.as_view(), name='mobile_app_login'), + + path('signup/details/', views.RegistrationDetailsView.as_view(), name='mobile_app_login'), + path('signup/password/', views.RegistrationPasswordView.as_view(), name='reset_password'), + + path('login/', views.LoginView.as_view(), name='mobile_app_login'), + + path('request-otp/', views.OtpRequestView.as_view(), name='send_otp'), + path('verify-otp/', views.OTPVerificationView.as_view(), name='send_otp'), + + # path('profile/view//', views.ProfileView.as_view(), name='profile_veiw'), + path('profile/view/', views.ProfileView.as_view(), name='profile_veiw'), + path('profile/edit/', views.ProfileView.as_view(), name='profile_edit'), + path('profile/password-reset/', views.ProfilePasswordResetView.as_view(), name='password_reset'), + # path('profile/kyc/', views.KycDocumentView.as_view(), name='pofile_kyc'), + + path('referral-code//', views.ReferralCodeViews.as_view(), name='referral_code'), + path('referral-record//', views.ReferralRecordViews.as_view(), name='referral_record'), + + path('google-login/', views.GoogleLoginAPIView.as_view(), name='google-login'), + + + + + +] diff --git a/accounts/api/utils.py b/accounts/api/utils.py new file mode 100644 index 0000000..d18dd71 --- /dev/null +++ b/accounts/api/utils.py @@ -0,0 +1,111 @@ +from rest_framework import status +from goodtimes import constants +from goodtimes.utils import ApiResponse +from accounts.models import IAmPrincipal, IAmPrincipalOtp +from django.contrib.auth.hashers import check_password +from rest_framework_simplejwt.tokens import RefreshToken + + +def generate_token_and_user_data(principal): + """ + Generate a token and user data based on an 'IAmPrincipal' object. + + Args: + principal (IAmPrincipal): The user object. + + Returns: + dict: A dictionary containing token data and user information. + """ + refresh = RefreshToken.for_user(principal) + data = { + "access": str(refresh.access_token), + "first_name": principal.first_name, + "last_name": principal.last_name, + "email": str(principal.email), + "complete": principal.register_complete, + } + return data + + +def authticate_with_otp_and_passsword(principal: IAmPrincipal, otp=None, password=None): + """ + Authenticate a principal using OTP and/or Password. + + Parameters: + - principal (User): The principal object to authenticate. + - otp (str, optional): One-Time Password (OTP). Default is None. + - password (str, optional): User's password. Default is None. + + Returns: + None: Successful authentication. + Response: Error response if authentication fails. + + Example: + ``` + principal = User.objects.get(phone_no='8987546598') + otp = request.data.get("otp") + password = request.data.get("password") + + result = authenticate_with_otp_and_password(principal, otp, password) + if isinstance(result, Response): + return result # Authentication failed, return error response + else: + # Authentication successful, proceed with authorized actions. + ``` + """ + + if not principal.is_active: + return ApiResponse.error( + message=constants.ACCOUNT_DEACTIVATED, errors=constants.ACCOUNT_DEACTIVATED + ) + + # Ensure that either OTP or password is provided + if otp is None and password is None: + return ApiResponse.error( + message=constants.OTP_OR_PASSWORD_REQUIRED, + errors=constants.OTP_OR_PASSWORD_REQUIRED, + ) + + if otp: + otp_instance = IAmPrincipalOtp.objects.filter( + principal=principal, otp_code=otp + ).last() + + if not otp_instance: + return ApiResponse.error( + message=constants.OTP_INVALID, errors=constants.OTP_INVALID + ) + + if otp_instance.is_expired(): + return ApiResponse.error( + message=constants.OTP_EXPIRED, errors=constants.OTP_EXPIRED + ) + + otp_instance.is_used = True + otp_instance.save() + + elif password: + print("password") + print(password) + validate = principal.check_password(password) + if not validate: + return ApiResponse.error( + message=constants.INVALID_PASSWORD, errors=constants.INVALID_PASSWORD + ) + print("validate", validate) + print("after passsowrd", password) + + # return None + + +def get_principal_by_email(email): + try: + principal = IAmPrincipal.objects.get(email=email) + return principal + except IAmPrincipal.DoesNotExist: + error_response = { + "status": status.HTTP_404_NOT_FOUND, + "message": constants.EMAIL_NOT_REGISTERED, + "errors": constants.EMAIL_NOT_REGISTERED, + } + return ApiResponse.error(**error_response) diff --git a/accounts/api/views.py b/accounts/api/views.py new file mode 100644 index 0000000..b4aee29 --- /dev/null +++ b/accounts/api/views.py @@ -0,0 +1,740 @@ +from django.db import transaction +from django.utils import timezone +from rest_framework import status +from rest_framework.views import APIView +from rest_framework_simplejwt.tokens import RefreshToken +from django.conf import settings +import requests + +# from .authenticate import authticate_with_otp_and_passsword +from accounts.models import ( + IAmPrincipal, + IAmPrincipalOtp, + IAmPrincipalType, + # IAmPrincipalKYCDetails, +) +from manage_referrals.models import ReferralCode, ReferralRecord +from goodtimes import constants +from goodtimes.services import SMSError, SMSService, EmailService + +# from nifty11_project.services import SMSError, SMSService +from goodtimes.utils import ApiResponse +from accounts.resource_action import ( + PRINCIPAL_TYPE_EVENT_USER, + PRINCIPAL_TYPE_EVENT_MANAGER, + PRINCIPAL_TYPE_FREE_USER, +) + +from .serializers import ( + EmailSerializers, + # RegistrationPasswordSerializer, + # PhoneSerializer, + EmailSerializer, + RegistrationPasswordSerializer, + RegistrationSerializer, + ReferralCodeSerializer, + ReferralRecordSerializer, + ProfileSerializer, + PasswordResetSerializer, + # PrincipalKYCDetailsSerializer, +) +from .utils import ( + generate_token_and_user_data, + authticate_with_otp_and_passsword, + get_principal_by_email, +) +from rest_framework.permissions import IsAuthenticated +from rest_framework_simplejwt.authentication import JWTAuthentication +from rest_framework.response import Response + + +class RegistrationEmailView(APIView): + authentication_classes = [] + permission_classes = [] + model = IAmPrincipal + + @transaction.atomic + def post(self, request): + serializer = EmailSerializer(data=request.data) + + if not serializer.is_valid(): + error_response = { + "status": status.HTTP_400_BAD_REQUEST, + "message": constants.REGISTRATION_FAIL, + "errors": serializer.errors, + } + return ApiResponse.error(**error_response) + + email = serializer.validated_data.get("email") + principal_type = serializer.validated_data.get("principal_type") + print("principal_type: ", principal_type) + principal = None # Declare user variable outside of try-except blocks + # default_email = f"nifty{phone_no[-10:]}@gmail.com" + default_password = f"GTMES{email[::-1]}" + + try: + principal = self.model.objects.get(email=email) + principal_type = IAmPrincipalType.get_principal_type(principal_type) + print(f"principal_type in try {principal_type}") + if principal.email_verified: + return ApiResponse.error( + message=constants.EMAIL_EXISTS, + errors=constants.EMAIL_EXISTS, + status=status.HTTP_403_FORBIDDEN, + ) + + # Update the existing user (unverified) with a default email and password + # principal.email = email + principal.principal_type = principal_type + principal.username = email + principal.set_password(default_password) + principal.save() + except self.model.DoesNotExist: + # Phone number doesn't exist, create a new user + principal = self.model.objects.create(email=email, username=email) + principal_type = IAmPrincipalType.get_principal_type(principal_type) + principal.principal_type = principal_type + principal.set_password(f"GTMES{email[::-1]}") + principal.save() + except Exception as e: + return ApiResponse.error(message=str(e), errors=str(e)) + + try: + # Send OTP to the user + otp = SMSService().create_otp( + principal=principal, opt_purpose="registration" + ) + # Create an instance of the EmailService + email_service = EmailService( + subject="Good Times - OTP", + to=[email], + from_email=settings.EMAIL_HOST_USER, + ) + email_service.load_template( + "otp/otp.html", context={"OTP": otp, "action": "Register"} + ) + # Send the email + email_service.send() + print("Email sent successfully!") + + except SMSError as e: + return ApiResponse.error(message=e.message, errors=e.message) + + return ApiResponse.success( + message=constants.OTP_SENT, data=int(otp) + ) # this will change + + +class RegistrationDetailsView(APIView): + authentication_classes = [] + permission_classes = [] + + @transaction.atomic + def post(self, request): + """ + Handle User Registration and Referral. + + This view processes user registration, validates data, creates new users, and tracks referrals if a referral code is provided. It returns success or error responses based on the registration outcome. + + Parameters: + - request (HttpRequest): HTTP request object with registration data. + + Returns: + - ApiResponse: Registration success or error response. + + Process: + 1. Extract data and validate registration. + 2. Create a new user and OTP. + 3. Generate referral codes for users. + 4. Track referrals if a referral code is provided and not alreay register thorugh referral. + 5. Return a response indicating success or error. + """ + # Extract the phone number from the request data + # phone_no = request.data.get("phone_no") + email = request.data.get("email") + print("email: ", email) + referral_code = request.data.get("referral_code") + print("referral_code", referral_code) + + # Filter the user instance by phone number through reusable function + principal = get_principal_by_email(email) + if isinstance(principal, Response): + return principal # returning error here as it is error instance + + # Validate the incoming data using the serializer + serializer = RegistrationSerializer(principal, data=request.data) + if not serializer.is_valid(): + error_response = { + "status": status.HTTP_400_BAD_REQUEST, + "message": constants.VALIDATION_ERROR, + "errors": serializer.errors, + } + return ApiResponse.error(**error_response) + + # Save the principal and related OTP + try: + instance = serializer.save() + instance.register_complete = True + instance.save() + except Exception as e: + print("instance: E-", e) + error_response = { + "status": status.HTTP_500_INTERNAL_SERVER_ERROR, + "message": constants.INTERNAL_SERVER_ERROR, + "errors": str(e), + } + return ApiResponse.error(**error_response) + + # generating referrall_code of the player and merchant + try: + ReferralCode.create_referral_code_for_user_manager( + principal=principal, principal_type=principal.principal_type + ) + except Exception as e: + print("ReferralCode: E-", e) + error_response = { + "status": status.HTTP_400_BAD_REQUEST, + "message": constants.FAILURE, + "errors": str(e), + } + return ApiResponse.error(**error_response) + + if referral_code: + already_register_through_referral = ReferralRecord.objects.filter( + referred_principal=instance + ).exists() + if not already_register_through_referral: + try: + whos_referral_code = ReferralCode.objects.get( + referral_code=referral_code + ) + except Exception as e: + print("whos_referral_code: E-", e) + error_response = { + "status": status.HTTP_404_NOT_FOUND, + "message": constants.RECORD_NOT_FOUND, + "errors": str(e), + } + return ApiResponse.error(**error_response) + + # principal_type = IAmPrincipalType.objects + + ReferralRecord.objects.create( + referrer_principal=whos_referral_code.principal, # principal id of the User who invited + referred_principal=instance, # principal id of the User who join through invitation + principal_type=whos_referral_code.principal_type, # principal type of the user who invited + is_completed=True, + ) + token_data = generate_token_and_user_data(principal) + token_data["type"] = str(principal.principal_type) + + return ApiResponse.success( + message=constants.REGISTRATION_SUCCESS, data=token_data + ) + + +class RegistrationPasswordView(APIView): + authentication_classes = [] + permission_classes = [] + + def post(self, request): + email = request.data.get("email") + # type = request.data.get("type") + # Filter the user instance by phone number through reusable function + principal = get_principal_by_email(email) + + if isinstance(principal, Response): + return principal # returning error here as it is error instance + + serializer = RegistrationPasswordSerializer(principal, data=request.data) + + if not serializer.is_valid(): + error_response = { + "status": status.HTTP_400_BAD_REQUEST, + "message": constants.VALIDATION_ERROR, + "errors": serializer.errors, + } + return ApiResponse.error(**error_response) + try: + principal = serializer.save() + principal.register_complete = True + principal.save() + except Exception as e: + error_response = { + "status": status.HTTP_500_INTERNAL_SERVER_ERROR, + "message": constants.INTERNAL_SERVER_ERROR, + "errors": str(e), + } + return ApiResponse.error(**error_response) + + token_data = generate_token_and_user_data(principal) + token_data["type"] = str(principal.principal_type) + + return ApiResponse.success( + message=constants.REGISTRATION_SUCCESS, data=token_data + ) + + +class OtpRequestView(APIView): + authentication_classes = [] + permission_classes = [] + + def post(self, request, *args, **kwargs): + serializer = EmailSerializers(data=request.data) + + if not serializer.is_valid(): + error_response = { + "status": status.HTTP_400_BAD_REQUEST, + "message": constants.VALIDATION_ERROR, + "errors": serializer.errors, + } + return ApiResponse.error(**error_response) + + email = serializer.validated_data.get("email") + + # Filter the user instance by phone number through reusable function + principal = get_principal_by_email(email) + + if isinstance(principal, Response): + return principal # returning error here as it is error instance + + try: + otp = SMSService().create_otp(principal=principal, opt_purpose="login") + # Create an instance of the EmailService + email_service = EmailService( + subject="Good Times - OTP", + to=[email], + from_email=settings.EMAIL_HOST_USER, + ) + email_service.load_template( + "otp/otp.html", context={"OTP": otp, "action": "Login"} + ) + # Send the email + email_service.send() + except SMSError as e: + return ApiResponse.error(message=e.message, errors=e.message) + + return ApiResponse.success(message=constants.OTP_SENT, data=int(otp)) + + +class OTPVerificationView(APIView): + authentication_classes = [] + permission_classes = [] + + def post(self, request): + serializer = EmailSerializers(data=request.data) + + if not serializer.is_valid(): + error_response = { + "status": status.HTTP_400_BAD_REQUEST, + "message": "Validation error", + "errors": serializer.errors, + } + return ApiResponse.error(**error_response) + + email = serializer.validated_data.get("email") + otp = request.data.get("otp") + + # Filter the user instance by phon + # ] + # 4\\\\\\\\\\\\\\\\\\\\\\\7e number through reusable function + principal = get_principal_by_email(email) + + if isinstance(principal, Response): + return principal # returning error here as it is error instance + + validation_result = authticate_with_otp_and_passsword(principal, otp=otp) + + if isinstance(validation_result, Response): + return validation_result # Return the error response if validation fails + + try: + principal.email_verified = True # set the phone_verified to true + principal.save() + except Exception as e: + error_response = { + "status": status.HTTP_500_INTERNAL_SERVER_ERROR, + "message": constants.INTERNAL_SERVER_ERROR, + "errors": str(e), + } + return ApiResponse.error(**error_response) + + return ApiResponse.success(message=constants.OTP_VERIFIED) + + +class LoginView(APIView): + authentication_classes = [] + permission_classes = [] + + def post(self, request, *args, **kwargs): + serializer = EmailSerializers(data=request.data) + if not serializer.is_valid(): + return Response( + { + "message": "Validation error", + "errors": serializer.errors, + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + email = serializer.validated_data.get("email") + otp = request.data.get("otp") + password = request.data.get("password") + + principal = get_principal_by_email(email) + if isinstance(principal, Response): + return principal # If get_principal_by_email returns a Response object, it's an error response. + + if not principal.is_active: + return Response( + { + "message": constants.ACCOUNT_DEACTIVATED, + "errors": constants.ACCOUNT_DEACTIVATED, + }, + status=status.HTTP_403_FORBIDDEN, + ) + + if not otp and not password: + return Response( + { + "message": constants.OTP_OR_PASSWORD_REQUIRED, + "errors": constants.OTP_OR_PASSWORD_REQUIRED, + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + if otp: + return self._process_otp_login(principal, otp) + elif password: + return self._process_password_login(principal, password) + + return Response( + {"message": constants.LOGIN_FAIL}, status=status.HTTP_400_BAD_REQUEST + ) + + def _process_otp_login(self, principal, otp): + otp_instance = IAmPrincipalOtp.objects.filter( + principal=principal, otp_code=otp + ).last() + if not otp_instance or otp_instance.is_expired(): + return Response( + { + "message": ( + constants.OTP_INVALID + if not otp_instance + else constants.OTP_EXPIRED + ), + "errors": ( + constants.OTP_INVALID + if not otp_instance + else constants.OTP_EXPIRED + ), + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + otp_instance.is_used = True + otp_instance.save() + + return self._login_success(principal) + + def _process_password_login(self, principal, password): + if not principal.check_password(password): + return Response( + { + "message": constants.INVALID_PASSWORD, + "errors": constants.INVALID_PASSWORD, + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + return self._login_success(principal) + + def _login_success(self, principal): + try: + principal.email_verified = True + principal.last_login = timezone.localtime(timezone.now()) + principal.save() + except Exception as e: + return Response( + { + "message": constants.INTERNAL_SERVER_ERROR, + "errors": str(e), + }, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + + token_data = generate_token_and_user_data(principal) + return Response( + { + "message": constants.LOGIN_SUCCESS, + "data": token_data, + }, + status=status.HTTP_200_OK, + ) + + +class ProfileView(APIView): + authentication_classes = [JWTAuthentication] + permission_classes = [IsAuthenticated] + model = IAmPrincipal + serializer = ProfileSerializer + + def get_object(self): + return self.request.user + + def get(self, request, *args, **kwargs): + instance = self.get_object() + context = {"request": request} + # context = {"principal_type": kwargs.get("principal_type"), "request": request} + serializer = self.serializer(instance, context=context) + success_response = { + "status": status.HTTP_200_OK, + "message": constants.SUCCESS, + "data": serializer.data, + } + return ApiResponse.success(**success_response) + + def post(self, request, *args, **kwargs): + instance = self.get_object() + serializer = self.serializer(instance, data=request.data) + if not serializer.is_valid(): + error_response = { + "status": status.HTTP_400_BAD_REQUEST, + "message": constants.VALIDATION_ERROR, + "errors": serializer.errors, + } + return ApiResponse.error(**error_response) + + try: + serializer.save() + except Exception as e: + return ApiResponse.error( + message=constants.INTERNAL_SERVER_ERROR, errors=str(e) + ) + return ApiResponse.success(message=constants.SUCCESS) + + +class ProfilePasswordResetView(APIView): + authentication_classes = [JWTAuthentication] + permission_classes = [IsAuthenticated] + model = IAmPrincipal + serializer = PasswordResetSerializer + + def post(self, request, *args, **kwargs): + current_password = request.data.get("current_password") + principal_obj = request.user + print(f"current password is {current_password}") + if current_password is None: + return ApiResponse.error( + message="Current password is required", + errors="Current password is required", + ) + + if not principal_obj.check_password(current_password): + return ApiResponse.error( + message="Invalid current password.", errors="Invalid current password." + ) + + serializer = self.serializer(instance=principal_obj, data=request.data) + + if not serializer.is_valid(): + error_response = { + "status": status.HTTP_400_BAD_REQUEST, + "message": "Validation error", + "errors": serializer.errors, + } + return ApiResponse.error(**error_response) + + try: + serializer.save() + except Exception as e: + error_response = { + "status": status.HTTP_400_BAD_REQUEST, + "message": constants.INTERNAL_SERVER_ERROR, + "errors": str(e), + } + return ApiResponse.error(**error_response) + return ApiResponse.success(message=constants.SUCCESS) + + +# class KycDocumentView(APIView): +# authentication_classes = [JWTAuthentication] +# permission_classes = [IsAuthenticated] +# model = IAmPrincipalKYCDetails +# serializer = PrincipalKYCDetailsSerializer + +# def get_object(self): +# try: +# return self.model.objects.get(principal=self.request.user) +# except self.model.DoesNotExist: +# return None + +# def get(self, request, *args, **kwargs): +# instance = self.get_object() +# serializer = self.serializer(context={"request": request}) +# if instance is not None: +# serializer.instance = instance # Update that instance if record exist + +# success_response = { +# "status": status.HTTP_200_OK, +# "message": constants.SUCCESS, +# "data": serializer.data, +# } +# return ApiResponse.success(**success_response) + +# def post(self, request, *args, **kwargs): +# instance = self.get_object() +# serializer = self.serializer( +# data=request.data, context={"request": request} +# ) # passing request context to update the principal from serializer + +# if instance is not None: +# serializer.instance = instance # Update that instance if record exist + +# if not serializer.is_valid(): +# error_response = { +# "status": status.HTTP_400_BAD_REQUEST, +# "message": constants.VALIDATION_ERROR, +# "errors": serializer.errors, +# } +# return ApiResponse.error(**error_response) + +# try: +# serializer.save() +# except Exception as e: +# return ApiResponse.error( +# message=constants.INTERNAL_SERVER_ERROR, errors=str(e) +# ) +# return ApiResponse.success(message=constants.SUCCESS) + + +class ReferralCodeViews(APIView): + authentication_classes = [JWTAuthentication] + permission_classes = [IsAuthenticated] + model = ReferralCode + serializer = ReferralCodeSerializer + + def get(self, request, *args, **kwargs): + referral_obj = self.model.filter_referral_code( + principal=request.user, principal_type=kwargs.get("principal_type") + ) + + serializer_obj = self.serializer(referral_obj, many=True) + + success_message = { + "status": status.HTTP_200_OK, + "message": constants.SUCCESS, + "data": serializer_obj.data, + } + return ApiResponse.success(**success_message) + + +class ReferralRecordViews(APIView): + authentication_classes = [JWTAuthentication] + permission_classes = [IsAuthenticated] + model = ReferralRecord + serializer = ReferralRecordSerializer + + def get(self, request, *args, **kwargs): + referral_obj = self.model.filter_invite_records( + referrer_principal=request.user, principal_type=kwargs.get("principal_type") + ) + + serializer_obj = self.serializer(referral_obj, many=True) + success_message = { + "status": status.HTTP_200_OK, + "message": constants.SUCCESS, + "data": {"count": referral_obj.count(), "record": serializer_obj.data}, + } + return ApiResponse.success(**success_message) + + +class GoogleLoginAPIView(APIView): + authentication_classes = [] + permission_classes = [] + + def post(self, request, *args, **kwargs): + access_token = request.data.get("access_token") + principal_type = request.data.get("principal_type") + if not access_token or not principal_type: + return Response( + {"error": "Access token & Principal Type is required"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + principal_info = self.get_google_user_data(access_token) + ("principal_info: ", principal_info) + if not principal_info: + return Response( + {"error": "Failed to fetch user details or invalid access token"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + email = principal_info.get("email") + if not email: + return Response( + {"error": "Email is required but not provided."}, + status=status.HTTP_400_BAD_REQUEST, + ) + ("email: ", email) + serializer = EmailSerializer( + data={"email": email, "principal_type": principal_type} + ) + if serializer.is_valid(): + print("Serializer is Validated..") + defaults = { + "first_name": principal_info.get("given_name", ""), + "last_name": principal_info.get("family_name", ""), + "register_complete": True, + "email_verified": True, + # 'principal_type': principal_type, # Uncomment if principal_type should be updated on each login + } + principal, created = IAmPrincipal.objects.update_or_create( + email=email, + defaults=defaults, + ) + + # If newly created, set password + if created: + print("New Created") + default_password = f"GTMES{email[::-1]}" + principal.set_password(default_password) + principal.principal_type = principal_type # Assuming principal_type should only be set on creation + principal.save() + + token_data = generate_token_and_user_data(principal) + token_data["type"] = str(principal.principal_type) + + message = "Already Registered and Verified User" + if created: + message = constants.REGISTRATION_SUCCESS + elif not principal.register_complete or not principal.email_verified: + message += ", details updated" + + return ApiResponse.success(message=message, data=token_data) + return ApiResponse.error( + status=status.HTTP_400_BAD_REQUEST, + message=constants.FAILURE, + error=serializer.errors, + ) + + def get_google_user_data(self, access_token): + user_info_endpoint = "https://www.googleapis.com/oauth2/v3/userinfo" + response = requests.get( + user_info_endpoint, params={"access_token": access_token} + ) + if response.status_code == 200: + return response.json() + if response.status_code != 200: + try: + error_details = response.json() + except ValueError: # Includes simplejson.decoder.JSONDecodeError + error_details = { + "error": "Failed to decode JSON response from Google API.", + "status": response.status_code, + } + return { + "error": error_details, + "status": response.status_code, + } diff --git a/accounts/apps.py b/accounts/apps.py new file mode 100644 index 0000000..3e3c765 --- /dev/null +++ b/accounts/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class AccountsConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'accounts' diff --git a/accounts/backend.py b/accounts/backend.py new file mode 100644 index 0000000..fb7b439 --- /dev/null +++ b/accounts/backend.py @@ -0,0 +1,49 @@ + +from django.contrib.auth import get_user_model +from django.contrib.auth.backends import ModelBackend +from django.contrib.auth.hashers import check_password + +from goodtimes import constants + +from .models import IAmPrincipalOtp + +class EmailBackend(ModelBackend): + """ + Custom Authentication Backend for Email and Password Authentication. + It extends Django's built-in 'ModelBackend' + + Methods: + - authenticate(self, request, email=None, password=None, **kwargs): Authenticate a user. + - get_user(self, user_id): Retrive a user by their user ID. + + Example: + ``` + # Authenticate a user using their email and password + user = EmailBackend.authenticate(request, email='user@example.com', password='password123') + + if user: + # Authentication successful, user is logged in. + else: + # Authentication failed, user is not logged in. + ``` + """ + UserModel = get_user_model() + + def authenticate(self, email=None, password=None, **kwargs): + + # Use a case-insensitive query for the email field + try: + user = self.UserModel.objects.get(email__iexact=email) + except self.UserModel.DoesNotExist: + return None + + # Use the user's `check_password` method to verify the password + if user.check_password(password): + return user + return None + + def get_user(self, user_id): + try: + return self.UserModel.objects.get(pk=user_id) + except self.UserModel.DoesNotExist: + return None diff --git a/accounts/context_processors.py b/accounts/context_processors.py new file mode 100644 index 0000000..97abd9b --- /dev/null +++ b/accounts/context_processors.py @@ -0,0 +1,47 @@ +from accounts import resource_action +from django.core.cache import cache + +CACHE_KEY_RESOURCE_ACTION_CONSTANTS = 'resource_action_constants' + +def resource_action_constants(request): + # Add debugging output + print("Resource and action constants context processor is executing.") + # Try to retrieve the constants from the cache + resource_action_constants = cache.get(CACHE_KEY_RESOURCE_ACTION_CONSTANTS) + # print(f"The value of '{CACHE_KEY_RESOURCE_ACTION_CONSTANTS}' in the cache is: {resource_action_constants}") + if resource_action_constants is None: + # Compute the constants if not found in the cache + resource_action_constants = compute_resource_action_constants() + print() + + # Store the constants in the cache with a timeout (e.g., 3600 seconds for 1 hour) + cache.set(CACHE_KEY_RESOURCE_ACTION_CONSTANTS, resource_action_constants, 3600) + + return { + 'resource_context': resource_action_constants, + } + +def compute_resource_action_constants(): + constants_dict = { + 'ACTION_CREATE': resource_action.ACTION_CREATE, + 'ACTION_READ': resource_action.ACTION_READ, + 'ACTION_UPDATE': resource_action.ACTION_UPDATE, + 'ACTION_DELETE': resource_action.ACTION_DELETE, + 'RESOURCE_MANAGE_DASHBOARD': resource_action.RESOURCE_MANAGE_DASHBOARD, + 'RESOURCE_MANAGE_IAM': resource_action.RESOURCE_MANAGE_IAM, + 'RESOURCE_MANAGE_CUSTOMER': resource_action.RESOURCE_MANAGE_CUSTOMER, + 'RESOURCE_MANAGE_WALLET': resource_action.RESOURCE_MANAGE_WALLET, + 'RESOURCE_MANAGE_PAYMENT': resource_action.RESOURCE_MANAGE_PAYMENT, + 'RESOURCE_MANAGE_EVENTS': resource_action.RESOURCE_MANAGE_EVENTS, + 'RESOURCE_MANAGE_CONTACT_US': resource_action.RESOURCE_MANAGE_CONTACT_US, + 'RESOURCE_MANAGE_CMS': resource_action.RESOURCE_MANAGE_CMS, + 'RESOURCE_MANAGE_REPORTS': resource_action.RESOURCE_MANAGE_REPORTS, + 'RESOURCE_MANAGE_SUBSCRIPTIONS': resource_action.RESOURCE_MANAGE_SUBSCRIPTIONS, + 'RESOURCE_MANAGE_REFERRALS': resource_action.RESOURCE_MANAGE_REFERRALS, + 'RESOURCE_MANAGE_FEEDBACK': resource_action.RESOURCE_MANAGE_FEEDBACK, + 'RESOURCE_IAM_PRINCIPAL': resource_action.RESOURCE_IAM_PRINCIPAL, + 'RESOURCE_IAM_PRINCIPAL_GROUP': resource_action.RESOURCE_IAM_PRINCIPAL_GROUP, + 'RESOURCE_IAM_GROUP': resource_action.RESOURCE_IAM_GROUP, + 'RESOURCE_IAM_ROLE': resource_action.RESOURCE_IAM_ROLE, + } + return constants_dict diff --git a/accounts/fixture_script.py b/accounts/fixture_script.py new file mode 100644 index 0000000..594d897 --- /dev/null +++ b/accounts/fixture_script.py @@ -0,0 +1,272 @@ +import json +import os + +from accounts.resource_action import ( + PRINCIPAL_TYPE_EVENT_USER, + PRINCIPAL_TYPE_EVENT_MANAGER, + PRINCIPAL_TYPE_FREE_USER, + PRINCIPAL_TYPE_ADMIN, + PRINCIPAL_TYPE_SUBADMIN, + ACTION_CREATE, + ACTION_READ, + ACTION_UPDATE, + ACTION_DELETE, + RESOURCE_MANAGE_DASHBOARD, + RESOURCE_MANAGE_IAM, + RESOURCE_MANAGE_CUSTOMER, + RESOURCE_MANAGE_WALLET, + RESOURCE_MANAGE_PAYMENT, + RESOURCE_MANAGE_EVENTS, + RESOURCE_MANAGE_CONTACT_US, + RESOURCE_MANAGE_CMS, + RESOURCE_MANAGE_REPORTS, + RESOURCE_MANAGE_SUBSCRIPTIONS, + RESOURCE_MANAGE_REFERRALS, + RESOURCE_MANAGE_FEEDBACK, +) +# this variable store the data of model principaltype, action, resource +fixture_data = [ + { + "model": "accounts.iamprincipaltype", + "pk": 1, + "fields": { + "name": PRINCIPAL_TYPE_EVENT_USER, + "label": PRINCIPAL_TYPE_EVENT_USER, + "slug": PRINCIPAL_TYPE_EVENT_USER, + "created_on": "2023-09-28T15:00:14.520", + "modified_on": "2023-09-28T15:00:14.526", + }, + }, + { + "model": "accounts.iamprincipaltype", + "pk": 2, + "fields": { + "name": PRINCIPAL_TYPE_ADMIN, + "label": PRINCIPAL_TYPE_ADMIN, + "slug": PRINCIPAL_TYPE_ADMIN, + "created_on": "2023-09-28T15:00:24.555", + "modified_on": "2023-09-28T15:00:24.556", + }, + }, + { + "model": "accounts.iamprincipaltype", + "pk": 3, + "fields": { + "name": PRINCIPAL_TYPE_SUBADMIN, + "label": PRINCIPAL_TYPE_SUBADMIN, + "slug": PRINCIPAL_TYPE_SUBADMIN, + "created_on": "2023-09-28T15:00:40.908", + "modified_on": "2023-09-28T15:00:40.908", + }, + }, + { + "model": "accounts.iamprincipaltype", + "pk": 4, + "fields": { + "name": PRINCIPAL_TYPE_EVENT_MANAGER, + "label": PRINCIPAL_TYPE_EVENT_MANAGER, + "slug": PRINCIPAL_TYPE_EVENT_MANAGER, + "created_on": "2023-09-28T15:00:40.908", + "modified_on": "2023-09-28T15:00:40.908", + }, + }, + { + "model": "accounts.iamprincipaltype", + "pk": 5, + "fields": { + "name": PRINCIPAL_TYPE_FREE_USER, + "label": PRINCIPAL_TYPE_FREE_USER, + "slug": PRINCIPAL_TYPE_FREE_USER, + "created_on": "2023-09-28T15:00:40.908", + "modified_on": "2023-09-28T15:00:40.908", + }, + }, + { + "model": "accounts.iamappaction", + "pk": 1, + "fields": { + "name": ACTION_CREATE, + "label": ACTION_CREATE, + "slug": ACTION_CREATE, + "created_on": "2023-09-28T16:52:16.756", + "modified_on": "2023-09-28T16:52:16.761", + }, + }, + { + "model": "accounts.iamappaction", + "pk": 2, + "fields": { + "name": ACTION_READ, + "label": ACTION_READ, + "slug": ACTION_READ, + "created_on": "2023-09-28T16:52:16.764", + "modified_on": "2023-09-28T16:52:16.764", + }, + }, + { + "model": "accounts.iamappaction", + "pk": 3, + "fields": { + "name": ACTION_UPDATE, + "label": ACTION_UPDATE, + "slug": ACTION_UPDATE, + "created_on": "2023-09-28T16:52:16.768", + "modified_on": "2023-09-28T16:52:16.768", + }, + }, + { + "model": "accounts.iamappaction", + "pk": 4, + "fields": { + "name": ACTION_DELETE, + "label": ACTION_DELETE, + "slug": ACTION_DELETE, + "created_on": "2023-09-28T16:52:16.770", + "modified_on": "2023-09-28T16:52:16.770", + }, + }, + { + "model": "accounts.iamappresource", + "pk": 1, + "fields": { + "name": RESOURCE_MANAGE_DASHBOARD, + "label": RESOURCE_MANAGE_DASHBOARD, + "slug": RESOURCE_MANAGE_DASHBOARD, + "created_on": "2023-09-28T16:17:42.783", + "modified_on": "2023-09-28T16:17:42.787", + "action": [1, 2, 3, 4], + }, + }, + { + "model": "accounts.iamappresource", + "pk": 2, + "fields": { + "name": RESOURCE_MANAGE_CUSTOMER, + "label": RESOURCE_MANAGE_CUSTOMER, + "slug": RESOURCE_MANAGE_CUSTOMER, + "created_on": "2023-09-28T16:17:42.791", + "modified_on": "2023-09-28T16:17:42.792", + "action": [1, 2, 3, 4], + }, + }, + { + "model": "accounts.iamappresource", + "pk": 3, + "fields": { + "name": RESOURCE_MANAGE_IAM, + "label": RESOURCE_MANAGE_IAM, + "slug": RESOURCE_MANAGE_IAM, + "created_on": "2023-09-28T16:17:42.795", + "modified_on": "2023-09-28T16:17:42.795", + "action": [1, 2, 3, 4], + }, + }, + { + "model": "accounts.iamappresource", + "pk": 4, + "fields": { + "name": RESOURCE_MANAGE_WALLET, + "label": RESOURCE_MANAGE_WALLET, + "slug": RESOURCE_MANAGE_WALLET, + "created_on": "2023-09-28T16:17:42.797", + "modified_on": "2023-09-28T16:17:42.797", + "action": [1, 2, 3, 4], + }, + }, + { + "model": "accounts.iamappresource", + "pk": 5, + "fields": { + "name": RESOURCE_MANAGE_PAYMENT, + "label": RESOURCE_MANAGE_PAYMENT, + "slug": RESOURCE_MANAGE_PAYMENT, + "created_on": "2023-09-28T16:17:42.797", + "modified_on": "2023-09-28T16:17:42.797", + "action": [1, 2, 3, 4], + }, + }, + { + "model": "accounts.iamappresource", + "pk": 6, + "fields": { + "name": RESOURCE_MANAGE_EVENTS, + "label": RESOURCE_MANAGE_EVENTS, + "slug": RESOURCE_MANAGE_EVENTS, + "created_on": "2023-09-28T16:17:42.801", + "modified_on": "2023-09-28T16:17:42.801", + "action": [1, 2, 3, 4], + }, + }, + { + "model": "accounts.iamappresource", + "pk": 7, + "fields": { + "name": RESOURCE_MANAGE_CONTACT_US, + "label": RESOURCE_MANAGE_CONTACT_US, + "slug": RESOURCE_MANAGE_CONTACT_US, + "created_on": "2023-09-28T16:17:42.804", + "modified_on": "2023-09-28T16:17:42.804", + "action": [1, 2, 3, 4], + }, + }, + { + "model": "accounts.iamappresource", + "pk": 8, + "fields": { + "name": RESOURCE_MANAGE_CMS, + "label": RESOURCE_MANAGE_CMS, + "slug": RESOURCE_MANAGE_CMS, + "created_on": "2023-09-28T16:17:42.806", + "modified_on": "2023-09-28T16:17:42.806", + "action": [1, 2, 3, 4], + }, + }, + { + "model": "accounts.iamappresource", + "pk": 9, + "fields": { + "name": RESOURCE_MANAGE_REPORTS, + "label": RESOURCE_MANAGE_REPORTS, + "slug": RESOURCE_MANAGE_REPORTS, + "created_on": "2023-09-28T16:17:42.809", + "modified_on": "2023-09-28T16:17:42.809", + "action": [1, 2, 3, 4], + }, + }, + { + "model": "accounts.iamappresource", + "pk": 10, + "fields": { + "name": RESOURCE_MANAGE_SUBSCRIPTIONS, + "label": RESOURCE_MANAGE_SUBSCRIPTIONS, + "slug": RESOURCE_MANAGE_SUBSCRIPTIONS, + "created_on": "2023-09-28T16:17:42.812", + "modified_on": "2023-09-28T16:17:42.812", + "action": [1, 2, 3, 4], + }, + }, + { + "model": "accounts.iamappresource", + "pk": 11, + "fields": { + "name": RESOURCE_MANAGE_FEEDBACK, + "label": RESOURCE_MANAGE_FEEDBACK, + "slug": RESOURCE_MANAGE_FEEDBACK, + "created_on": "2023-09-28T16:17:42.815", + "modified_on": "2023-09-28T16:17:42.815", + "action": [1, 2, 3, 4], + }, + }, + { + "model": "accounts.iamappresource", + "pk": 12, + "fields": { + "name": RESOURCE_MANAGE_REFERRALS, + "label": RESOURCE_MANAGE_REFERRALS, + "slug": RESOURCE_MANAGE_REFERRALS, + "created_on": "2023-09-28T16:17:42.815", + "modified_on": "2023-09-28T16:17:42.815", + "action": [1, 2, 3, 4], + }, + }, +] diff --git a/accounts/fixtures/resource_action_fixture.json b/accounts/fixtures/resource_action_fixture.json new file mode 100644 index 0000000..a671277 --- /dev/null +++ b/accounts/fixtures/resource_action_fixture.json @@ -0,0 +1,305 @@ +[ + { + "model": "accounts.iamprincipaltype", + "pk": 1, + "fields": { + "name": "event_user", + "label": "event_user", + "slug": "event_user", + "created_on": "2023-09-28T15:00:14.520", + "modified_on": "2023-09-28T15:00:14.526" + } + }, + { + "model": "accounts.iamprincipaltype", + "pk": 2, + "fields": { + "name": "admin", + "label": "admin", + "slug": "admin", + "created_on": "2023-09-28T15:00:24.555", + "modified_on": "2023-09-28T15:00:24.556" + } + }, + { + "model": "accounts.iamprincipaltype", + "pk": 3, + "fields": { + "name": "subadmin", + "label": "subadmin", + "slug": "subadmin", + "created_on": "2023-09-28T15:00:40.908", + "modified_on": "2023-09-28T15:00:40.908" + } + }, + { + "model": "accounts.iamprincipaltype", + "pk": 4, + "fields": { + "name": "event_manager", + "label": "event_manager", + "slug": "event_manager", + "created_on": "2023-09-28T15:00:40.908", + "modified_on": "2023-09-28T15:00:40.908" + } + }, + { + "model": "accounts.iamprincipaltype", + "pk": 5, + "fields": { + "name": "free_user", + "label": "free_user", + "slug": "free_user", + "created_on": "2023-09-28T15:00:40.908", + "modified_on": "2023-09-28T15:00:40.908" + } + }, + { + "model": "accounts.iamappaction", + "pk": 1, + "fields": { + "name": "create", + "label": "create", + "slug": "create", + "created_on": "2023-09-28T16:52:16.756", + "modified_on": "2023-09-28T16:52:16.761" + } + }, + { + "model": "accounts.iamappaction", + "pk": 2, + "fields": { + "name": "read", + "label": "read", + "slug": "read", + "created_on": "2023-09-28T16:52:16.764", + "modified_on": "2023-09-28T16:52:16.764" + } + }, + { + "model": "accounts.iamappaction", + "pk": 3, + "fields": { + "name": "update", + "label": "update", + "slug": "update", + "created_on": "2023-09-28T16:52:16.768", + "modified_on": "2023-09-28T16:52:16.768" + } + }, + { + "model": "accounts.iamappaction", + "pk": 4, + "fields": { + "name": "delete", + "label": "delete", + "slug": "delete", + "created_on": "2023-09-28T16:52:16.770", + "modified_on": "2023-09-28T16:52:16.770" + } + }, + { + "model": "accounts.iamappresource", + "pk": 1, + "fields": { + "name": "manage_dashboard", + "label": "manage_dashboard", + "slug": "manage_dashboard", + "created_on": "2023-09-28T16:17:42.783", + "modified_on": "2023-09-28T16:17:42.787", + "action": [ + 1, + 2, + 3, + 4 + ] + } + }, + { + "model": "accounts.iamappresource", + "pk": 2, + "fields": { + "name": "manage_customer", + "label": "manage_customer", + "slug": "manage_customer", + "created_on": "2023-09-28T16:17:42.791", + "modified_on": "2023-09-28T16:17:42.792", + "action": [ + 1, + 2, + 3, + 4 + ] + } + }, + { + "model": "accounts.iamappresource", + "pk": 3, + "fields": { + "name": "manage_iam", + "label": "manage_iam", + "slug": "manage_iam", + "created_on": "2023-09-28T16:17:42.795", + "modified_on": "2023-09-28T16:17:42.795", + "action": [ + 1, + 2, + 3, + 4 + ] + } + }, + { + "model": "accounts.iamappresource", + "pk": 4, + "fields": { + "name": "manage_wallet", + "label": "manage_wallet", + "slug": "manage_wallet", + "created_on": "2023-09-28T16:17:42.797", + "modified_on": "2023-09-28T16:17:42.797", + "action": [ + 1, + 2, + 3, + 4 + ] + } + }, + { + "model": "accounts.iamappresource", + "pk": 5, + "fields": { + "name": "manage_payment", + "label": "manage_payment", + "slug": "manage_payment", + "created_on": "2023-09-28T16:17:42.797", + "modified_on": "2023-09-28T16:17:42.797", + "action": [ + 1, + 2, + 3, + 4 + ] + } + }, + { + "model": "accounts.iamappresource", + "pk": 6, + "fields": { + "name": "manage_events", + "label": "manage_events", + "slug": "manage_events", + "created_on": "2023-09-28T16:17:42.801", + "modified_on": "2023-09-28T16:17:42.801", + "action": [ + 1, + 2, + 3, + 4 + ] + } + }, + { + "model": "accounts.iamappresource", + "pk": 7, + "fields": { + "name": "manage_contact_us", + "label": "manage_contact_us", + "slug": "manage_contact_us", + "created_on": "2023-09-28T16:17:42.804", + "modified_on": "2023-09-28T16:17:42.804", + "action": [ + 1, + 2, + 3, + 4 + ] + } + }, + { + "model": "accounts.iamappresource", + "pk": 8, + "fields": { + "name": "manage_cms", + "label": "manage_cms", + "slug": "manage_cms", + "created_on": "2023-09-28T16:17:42.806", + "modified_on": "2023-09-28T16:17:42.806", + "action": [ + 1, + 2, + 3, + 4 + ] + } + }, + { + "model": "accounts.iamappresource", + "pk": 9, + "fields": { + "name": "manage_reports", + "label": "manage_reports", + "slug": "manage_reports", + "created_on": "2023-09-28T16:17:42.809", + "modified_on": "2023-09-28T16:17:42.809", + "action": [ + 1, + 2, + 3, + 4 + ] + } + }, + { + "model": "accounts.iamappresource", + "pk": 10, + "fields": { + "name": "manage_subscriptions", + "label": "manage_subscriptions", + "slug": "manage_subscriptions", + "created_on": "2023-09-28T16:17:42.812", + "modified_on": "2023-09-28T16:17:42.812", + "action": [ + 1, + 2, + 3, + 4 + ] + } + }, + { + "model": "accounts.iamappresource", + "pk": 11, + "fields": { + "name": "manage_feedback", + "label": "manage_feedback", + "slug": "manage_feedback", + "created_on": "2023-09-28T16:17:42.815", + "modified_on": "2023-09-28T16:17:42.815", + "action": [ + 1, + 2, + 3, + 4 + ] + } + }, + { + "model": "accounts.iamappresource", + "pk": 12, + "fields": { + "name": "manage_referrals", + "label": "manage_referrals", + "slug": "manage_referrals", + "created_on": "2023-09-28T16:17:42.815", + "modified_on": "2023-09-28T16:17:42.815", + "action": [ + 1, + 2, + 3, + 4 + ] + } + } +] \ No newline at end of file diff --git a/accounts/forms.py b/accounts/forms.py new file mode 100644 index 0000000..2b22247 --- /dev/null +++ b/accounts/forms.py @@ -0,0 +1,321 @@ +from typing import Any + +from django import forms +from django.core.exceptions import ValidationError +from django.core import validators +from django.utils.translation import gettext_lazy as _ + +from goodtimes import constants + +from . import models +# from .backend import EmailBackend +from phonenumber_field.formfields import PhoneNumberField +from accounts.resource_action import PRINCIPAL_TYPE_ADMIN, PRINCIPAL_TYPE_SUBADMIN +from django.contrib.auth import authenticate + + +class CustomAuthenticationForm(forms.Form): + email = forms.EmailField( + max_length=254, + widget=forms.TextInput(attrs={"autofocus": True}), + label=_("Email"), + ) + password = forms.CharField( + label=_("Password"), + strip=False, + widget=forms.PasswordInput(attrs={"autocomplete": "current-password"}), + ) + + def clean(self): + email = self.cleaned_data.get("email") + password = self.cleaned_data.get("password") + self.user = None + if email and password: + + user = authenticate(email=email, password=password) + + if user is None: + raise ValidationError({"__all__": [constants.INVALID_EMAIL_PASSWORD]}) + elif not user.is_active: + raise ValidationError({"__all__": [constants.ACCOUNT_DEACTIVATED]}) + self.user = user + return self.cleaned_data + + +class IAmPrincipalForm(forms.ModelForm): + password = forms.CharField( + widget=forms.PasswordInput(attrs={"autocomplete": "off"}), + validators=[ + validators.MinLengthValidator( + limit_value=6, message="Password must be at least 6 characters long. " + ) + ], + ) + confirm_password = forms.CharField( + widget=forms.PasswordInput(attrs={"autocomplete": "off"}) + ) + + is_active = forms.BooleanField( + label="Active", + initial=True, + help_text="Designates whether this user should be treated as active. Unselect this instead of deleting accounts.", + required=False, + ) + + class Meta: + model = models.IAmPrincipal + fields = [ + "principal_type", + "first_name", + "last_name", + "email", + "password", + "confirm_password", + "is_active", + ] + + def __init__(self, *args, **kwargs): + instance = kwargs.get("instance") + super().__init__(*args, **kwargs) + self.fields["principal_type"].queryset = models.IAmPrincipalType.objects.filter( + active=True, deleted=False + ) + # If it's a create action, exclude 'is_active' field + if instance is None: + self.fields.pop("is_active", None) + else: + # Exclude 'password' and 'confirm_password' fields for updates + self.fields.pop("password", None) + self.fields.pop("confirm_password", None) + + # Make the 'email' field read-only + self.fields["email"].widget.attrs["readonly"] = True + + def clean_email(self): + email = self.cleaned_data.get("email") + # Skip uniqueness validation if it's an update action (instance exists) + if self.instance and self.instance.email == email: + return email + if models.IAmPrincipal.objects.filter(email=email).exists(): + raise forms.ValidationError(constants.EMAIL_EXISTS) + + return email + + def save(self, commit=True): + instance = super().save(commit=False) + # Check if it's a new object (create action) or an existing one (update action) + if not instance.pk: # pk is None for new objects + instance.username = self.cleaned_data["email"] + instance.set_password(self.cleaned_data["password"]) + + principal_type = self.cleaned_data.get("principal_type") + if principal_type is not None: + # Set is_superuser and is_staff based on principal_type + if principal_type == models.IAmPrincipalType.objects.get(name=PRINCIPAL_TYPE_ADMIN): + instance.is_superuser = True + elif principal_type == models.IAmPrincipalType.objects.get(name=PRINCIPAL_TYPE_SUBADMIN): + instance.is_staff = True + if commit: + instance.save() + return instance + + +class IAmPrincipalProfileForm(forms.ModelForm): + GENDER_CHOICES = ( + ("male", "Male"), + ("female", "Female"), + ("other", "Other"), + ) + first_name = forms.CharField(required=True) + last_name = forms.CharField(required=True) + email = forms.EmailField(required=True) + password = forms.CharField( + widget=forms.PasswordInput(attrs={"autocomplete": "off"}) + ) + confirm_password = forms.CharField( + widget=forms.PasswordInput(attrs={"autocomplete": "off"}) + ) + # date_of_birth = forms.CharField(widget=forms.DateInput(attrs={'type': 'date'})) + phone_number = PhoneNumberField( + widget=forms.TextInput(), + ) + # is_staff = forms.BooleanField( + # label="Staff Status", + # label_suffix="", + # initial=True, + # required=False, + # help_text="Check this box to designate that this user will be assigned permissions in the future.", + # ) + # is_superuser = forms.BooleanField( + # label="SuperAdmin Status", + # label_suffix="", + # required=False, + # help_text="Check this box to designates that this user has all permissions without explicitly assigning them.", + # ) + # gender = forms.ChoiceField(choices=GENDER_CHOICES) + + class Meta: + model = models.IAmPrincipal + fields = [ + "principal_type", + "first_name", + "last_name", + "email", + "password", + "confirm_password", + # 'gender', + # 'date_of_birth', + "phone_number", + # 'address_line1', + # 'address_line2', + # 'city', + # 'state', + # 'country', + # 'post_code', + # 'profile_photo', + # "is_staff", + # "is_superuser", + ] + + def __init__(self, *args, **kwargs): + instance = kwargs.get("instance") + super().__init__(*args, **kwargs) + self.fields["principal_type"].queryset = models.IAmPrincipalType.objects.filter( + active=True, deleted=False + ) + # self.fields['principal_source'].queryset = models.IAmPrincipalSource.objects.filter(active=True, deleted=False) + # Check if an instance is provided and customize the form fields accordingly + if instance is not None: + # Exclude the 'password' and 'confirm_password' fields + self.fields.pop("password", None) + self.fields.pop("confirm_password", None) + + # Make the 'email' field read-only + self.fields["email"].widget.attrs["readonly"] = True + + # Modify the 'is_superuser' field to be not required + # self.fields["is_superuser"].required = False + # self.fields["is_staff"].required = False + + # Add or modify the 'is_active' field + self.fields["is_active"] = forms.BooleanField( + label="Active", + initial=instance.is_active, + help_text="Designates whether this user should be treated as active. Unselect this instead of deleting accounts.", + required=False, + ) + + def clean(self): + cleaned_data = super().clean() + password = cleaned_data.get("password") + confirm_password = cleaned_data.get("confirm_password") + + if password and confirm_password and password != confirm_password: + self.add_error("confirm_password", "Password does not match") + return cleaned_data + + def save(self, commit=True): + user = super().save(commit=False) + user.set_password(self.cleaned_data["password"]) + if commit: + user.save() + return user + +class ProfileEditForm(forms.ModelForm): + profile_photo = forms.ImageField(required=False) + + class Meta: + model = models.IAmPrincipal + fields = [ + "profile_photo", + "first_name", + "last_name" + ] + + +class IAmPrincipalGroupLinkForm(forms.ModelForm): + + class Meta: + model = models.IAmPrincipal + fields = [ + # "principal_type", + "email", + "principal_group", + ] + + # principal_type = forms.ModelChoiceField( + # label="Principal Type", + # queryset=models.IAmPrincipalType.objects.filter(active=True, deleted=False), + # widget=forms.widgets.TextInput(attrs={"readonly": True}), + # ) + principal_group = forms.ModelMultipleChoiceField( + label="Groups", + queryset=models.IAmPrincipalGroup.objects.filter(active=True, deleted=False), + required=False, + widget=forms.widgets.SelectMultiple( + attrs={"class": "form_select js-example-basic-multiple"} + ), + ) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Make the 'email' field read-only + # self.fields['principal_type'].widget.attrs['disabled'] = True + self.fields['email'].widget.attrs['readonly'] = True + + +class IAmPrincipalTypeForm(forms.ModelForm): + class Meta: + model = models.IAmPrincipalType + fields = ["name", "active"] + + def __init__(self, *args, **kwargs): + instance = kwargs.get("instance") + super().__init__(*args, **kwargs) + + if instance is None: + self.fields.pop("active") + + +class IAmPrincipalGroupRoleLinkForm(forms.ModelForm): + class Meta: + model = models.IAmPrincipalGroup + fields = ["name", "role", "active"] + + role = forms.ModelMultipleChoiceField( + queryset=models.IAmRole.objects.filter(active=True, deleted=False), + required=False, + widget=forms.widgets.SelectMultiple( + attrs={"class": "form-select js-example-basic-multiple"} + ), + ) + + def __init__(self, *args, **kwargs): + instance = kwargs.get("instance") + # data = kwargs.get('data') + super().__init__(*args, **kwargs) + + if instance is None: + # This is an add operation, exclude the 'active' field + self.fields.pop("active") + + +class IAmPrincipalRoleAppResourceActionLinkForm(forms.ModelForm): + class Meta: + model = models.IAmRole + fields = ["name", "active", "app_resource_action"] + required = {"app_resource_action": False} + + app_resource_action = forms.ModelMultipleChoiceField( + queryset=models.IAmAppResourceActionLink.objects.all(), + widget=forms.CheckboxSelectMultiple, + required=False, + ) + + def __init__(self, *args, **kwargs): + instance = kwargs.get("instance") + super().__init__(*args, **kwargs) + + if instance is None: + self.fields.pop("active") diff --git a/accounts/google_login.py b/accounts/google_login.py new file mode 100644 index 0000000..908961e --- /dev/null +++ b/accounts/google_login.py @@ -0,0 +1,86 @@ +from django.conf import settings +from django.core.exceptions import ImproperlyConfigured +from attrs import define +from random import SystemRandom +from urllib.parse import urlencode +from django.urls import reverse_lazy +from oauthlib.common import UNICODE_ASCII_CHARACTER_SET + + +@define +class GoogleRawLoginCredentials: + client_id: str + client_secret: str + project_id: str + + +def google_raw_login_get_credentials() -> GoogleRawLoginCredentials: + client_id = settings.GOOGLE_OAUTH2_CLIENT_ID + client_secret = settings.GOOGLE_OAUTH2_CLIENT_SECRET + project_id = settings.GOOGLE_OAUTH2_PROJECT_ID + + if not client_id: + raise ImproperlyConfigured("GOOGLE_OAUTH2_CLIENT_ID missing in env.") + + if not client_secret: + raise ImproperlyConfigured("GOOGLE_OAUTH2_CLIENT_SECRET missing in env.") + + if not project_id: + raise ImproperlyConfigured("GOOGLE_OAUTH2_PROJECT_ID missing in env.") + + credentials = GoogleRawLoginCredentials( + client_id=client_id, client_secret=client_secret, project_id=project_id + ) + + return credentials + + +class GoogleRawLoginFlowService: + API_URI = reverse_lazy("api:google-oauth2:login-raw:callback-raw") + + GOOGLE_AUTH_URL = "https://accounts.google.com/o/oauth2/auth" + GOOGLE_ACCESS_TOKEN_OBTAIN_URL = "https://oauth2.googleapis.com/token" + GOOGLE_USER_INFO_URL = "https://www.googleapis.com/oauth2/v3/userinfo" + + SCOPES = [ + "https://www.googleapis.com/auth/userinfo.email", + "https://www.googleapis.com/auth/userinfo.profile", + "openid", + ] + + def __init__(self): + self._credentials = google_raw_login_get_credentials() + + @staticmethod + def _generate_state_session_token(length=30, chars=UNICODE_ASCII_CHARACTER_SET): + # This is how it's implemented in the official SDK + rand = SystemRandom() + state = "".join(rand.choice(chars) for _ in range(length)) + return state + + def _get_redirect_uri(self): + domain = settings.BASE_BACKEND_URL + api_uri = self.API_URI + redirect_uri = f"{domain}{api_uri}" + return redirect_uri + + def get_authorization_url(self): + redirect_uri = self._get_redirect_uri() + + state = self._generate_state_session_token() + + params = { + "response_type": "code", + "client_id": self._credentials.client_id, + "redirect_uri": redirect_uri, + "scope": " ".join(self.SCOPES), + "state": state, + "access_type": "offline", + "include_granted_scopes": "true", + "prompt": "select_account", + } + + query_params = urlencode(params) + authorization_url = f"{self.GOOGLE_AUTH_URL}?{query_params}" + + return authorization_url, state diff --git a/accounts/management/commands/load_custom_fixture.py b/accounts/management/commands/load_custom_fixture.py new file mode 100644 index 0000000..dac7db9 --- /dev/null +++ b/accounts/management/commands/load_custom_fixture.py @@ -0,0 +1,52 @@ +import json +import os +from typing import Any +import subprocess +from django.core.management.base import BaseCommand, CommandError +from tqdm import tqdm + +from accounts.fixture_script import fixture_data + + +class Command(BaseCommand): + help = "Load custom fixture data." + + def handle(self, *args, **options): + try: + self.stdout.write(self.style.SUCCESS("Fixture data loading started...")) + + app_name = "accounts" + fixtures_directory = os.path.join(app_name, "fixtures") + fixture_filename = os.path.join(fixtures_directory, "resource_action_fixture.json") + + fixture_data_list = [] # Create an empty list to hold fixture data + + with tqdm(total=len(fixture_data)) as pbar: + for item in fixture_data: + fixture_data_list.append(item) # Append each data item to the list + pbar.update(1) + + # Dump the entire fixture data list as a JSON array + with open(fixture_filename, "w") as fixture_file: + json.dump(fixture_data_list, fixture_file, indent=4) + + self.stdout.write( + self.style.SUCCESS(f"Fixture data has been loaded successfully. Fixture file location: {fixture_filename}") + ) + + # Validate the generated JSON + try: + with open(fixture_filename, "r") as f: + json.load(f) + except json.JSONDecodeError as e: + raise CommandError(f"Invalid JSON in fixture file: {str(e)}") + + # Run the loaddata command to load the created fixture + loaddata_command = f"python manage.py loaddata {fixture_filename}" + subprocess.run(loaddata_command, shell=True) + + except Exception as e: + # Handle exceptions here + self.stderr.write( + self.style.ERROR(f"Fixture data loading failed: {str(e)}") + ) diff --git a/accounts/middleware.py b/accounts/middleware.py new file mode 100644 index 0000000..71d1c78 --- /dev/null +++ b/accounts/middleware.py @@ -0,0 +1,16 @@ +from accounts.utils import UserContext + + +class UserContextMiddleware: + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + if request.user.is_authenticated: + UserContext.set_user(request.user) + else: + UserContext.set_user(None) + + response = self.get_response(request) + return response + diff --git a/accounts/migrations/0001_initial.py b/accounts/migrations/0001_initial.py new file mode 100644 index 0000000..01994ff --- /dev/null +++ b/accounts/migrations/0001_initial.py @@ -0,0 +1,760 @@ +# Generated by Django 5.0.2 on 2024-02-29 07:47 + +import django.contrib.auth.validators +import django.db.models.deletion +import django.utils.timezone +import phonenumber_field.modelfields +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ("auth", "0012_alter_user_first_name_max_length"), + ] + + operations = [ + migrations.CreateModel( + name="IAmAppAction", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=255)), + ("label", models.CharField(blank=True, max_length=255, null=True)), + ("slug", models.SlugField(blank=True, max_length=255, null=True)), + ("sort_order", models.IntegerField(blank=True, null=True)), + ( + "small_image_url", + models.ImageField(blank=True, null=True, upload_to=""), + ), + ( + "large_image_url", + models.ImageField(blank=True, null=True, upload_to=""), + ), + ("active", models.BooleanField(default=True)), + ("deleted", models.BooleanField(default=False)), + ("created_by", models.SmallIntegerField(blank=True, null=True)), + ("created_on", models.DateTimeField(auto_now_add=True)), + ("modified_by", models.SmallIntegerField(blank=True, null=True)), + ("modified_on", models.DateTimeField(auto_now=True)), + ], + options={ + "db_table": "iam_app_action", + }, + ), + migrations.CreateModel( + name="IAmAppResource", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=255)), + ("label", models.CharField(blank=True, max_length=255, null=True)), + ("slug", models.SlugField(blank=True, max_length=255, null=True)), + ("sort_order", models.IntegerField(blank=True, null=True)), + ( + "small_image_url", + models.ImageField(blank=True, null=True, upload_to=""), + ), + ( + "large_image_url", + models.ImageField(blank=True, null=True, upload_to=""), + ), + ("active", models.BooleanField(default=True)), + ("deleted", models.BooleanField(default=False)), + ("created_by", models.SmallIntegerField(blank=True, null=True)), + ("created_on", models.DateTimeField(auto_now_add=True)), + ("modified_by", models.SmallIntegerField(blank=True, null=True)), + ("modified_on", models.DateTimeField(auto_now=True)), + ], + options={ + "db_table": "iam_app_resource", + }, + ), + migrations.CreateModel( + name="IAmPrincipalGroup", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=255)), + ("label", models.CharField(blank=True, max_length=255, null=True)), + ("slug", models.SlugField(blank=True, max_length=255, null=True)), + ("sort_order", models.IntegerField(blank=True, null=True)), + ( + "small_image_url", + models.ImageField(blank=True, null=True, upload_to=""), + ), + ( + "large_image_url", + models.ImageField(blank=True, null=True, upload_to=""), + ), + ("active", models.BooleanField(default=True)), + ("deleted", models.BooleanField(default=False)), + ("created_by", models.SmallIntegerField(blank=True, null=True)), + ("created_on", models.DateTimeField(auto_now_add=True)), + ("modified_by", models.SmallIntegerField(blank=True, null=True)), + ("modified_on", models.DateTimeField(auto_now=True)), + ], + options={ + "db_table": "iam_principal_group", + }, + ), + migrations.CreateModel( + name="IAmPrincipalSource", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=255)), + ("label", models.CharField(blank=True, max_length=255, null=True)), + ("slug", models.SlugField(blank=True, max_length=255, null=True)), + ("sort_order", models.IntegerField(blank=True, null=True)), + ( + "small_image_url", + models.ImageField(blank=True, null=True, upload_to=""), + ), + ( + "large_image_url", + models.ImageField(blank=True, null=True, upload_to=""), + ), + ("active", models.BooleanField(default=True)), + ("deleted", models.BooleanField(default=False)), + ("created_by", models.SmallIntegerField(blank=True, null=True)), + ("created_on", models.DateTimeField(auto_now_add=True)), + ("modified_by", models.SmallIntegerField(blank=True, null=True)), + ("modified_on", models.DateTimeField(auto_now=True)), + ], + options={ + "db_table": "iam_principal_source", + }, + ), + migrations.CreateModel( + name="IAmPrincipalType", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=255)), + ("label", models.CharField(blank=True, max_length=255, null=True)), + ("slug", models.SlugField(blank=True, max_length=255, null=True)), + ("sort_order", models.IntegerField(blank=True, null=True)), + ( + "small_image_url", + models.ImageField(blank=True, null=True, upload_to=""), + ), + ( + "large_image_url", + models.ImageField(blank=True, null=True, upload_to=""), + ), + ("active", models.BooleanField(default=True)), + ("deleted", models.BooleanField(default=False)), + ("created_by", models.SmallIntegerField(blank=True, null=True)), + ("created_on", models.DateTimeField(auto_now_add=True)), + ("modified_by", models.SmallIntegerField(blank=True, null=True)), + ("modified_on", models.DateTimeField(auto_now=True)), + ], + options={ + "db_table": "iam_principal_type", + }, + ), + migrations.CreateModel( + name="IAmRole", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=255)), + ("label", models.CharField(blank=True, max_length=255, null=True)), + ("slug", models.SlugField(blank=True, max_length=255, null=True)), + ("sort_order", models.IntegerField(blank=True, null=True)), + ( + "small_image_url", + models.ImageField(blank=True, null=True, upload_to=""), + ), + ( + "large_image_url", + models.ImageField(blank=True, null=True, upload_to=""), + ), + ("active", models.BooleanField(default=True)), + ("deleted", models.BooleanField(default=False)), + ("created_by", models.SmallIntegerField(blank=True, null=True)), + ("created_on", models.DateTimeField(auto_now_add=True)), + ("modified_by", models.SmallIntegerField(blank=True, null=True)), + ("modified_on", models.DateTimeField(auto_now=True)), + ], + options={ + "db_table": "iam_role", + }, + ), + migrations.CreateModel( + name="IAmPrincipal", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("password", models.CharField(max_length=128, verbose_name="password")), + ( + "last_login", + models.DateTimeField( + blank=True, null=True, verbose_name="last login" + ), + ), + ( + "is_superuser", + models.BooleanField( + default=False, + help_text="Designates that this user has all permissions without explicitly assigning them.", + verbose_name="superuser status", + ), + ), + ( + "username", + models.CharField( + error_messages={ + "unique": "A user with that username already exists." + }, + help_text="Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.", + max_length=150, + unique=True, + validators=[ + django.contrib.auth.validators.UnicodeUsernameValidator() + ], + verbose_name="username", + ), + ), + ( + "first_name", + models.CharField( + blank=True, max_length=150, verbose_name="first name" + ), + ), + ( + "last_name", + models.CharField( + blank=True, max_length=150, verbose_name="last name" + ), + ), + ( + "is_staff", + models.BooleanField( + default=False, + help_text="Designates whether the user can log into this admin site.", + verbose_name="staff status", + ), + ), + ( + "is_active", + models.BooleanField( + default=True, + help_text="Designates whether this user should be treated as active. Unselect this instead of deleting accounts.", + verbose_name="active", + ), + ), + ( + "date_joined", + models.DateTimeField( + default=django.utils.timezone.now, verbose_name="date joined" + ), + ), + ("email", models.EmailField(max_length=254, unique=True)), + ("gender", models.CharField(blank=True, max_length=5, null=True)), + ("date_of_birth", models.DateField(blank=True, null=True)), + ( + "phone_no", + phonenumber_field.modelfields.PhoneNumberField( + blank=True, max_length=128, null=True, region=None + ), + ), + ("address_line1", models.TextField(blank=True, null=True)), + ("address_line2", models.TextField(blank=True, null=True)), + ("city", models.CharField(blank=True, max_length=100, null=True)), + ("state", models.CharField(blank=True, max_length=100, null=True)), + ("country", models.CharField(blank=True, max_length=100, null=True)), + ("post_code", models.CharField(blank=True, max_length=100, null=True)), + ( + "profile_photo", + models.ImageField(blank=True, null=True, upload_to="profile"), + ), + ("phone_verified", models.BooleanField(default=False)), + ("email_verified", models.BooleanField(default=False)), + ( + "referral_code", + models.CharField(blank=True, max_length=50, null=True), + ), + ("created_on", models.DateTimeField(auto_now_add=True)), + ("modified_on", models.DateTimeField(auto_now=True)), + ("deleted", models.BooleanField(default=False)), + ("register_complete", models.BooleanField(default=False)), + ( + "created_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="creations", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "groups", + models.ManyToManyField( + blank=True, + help_text="The groups this user belongs to. A user will get all permissions granted to each of their groups.", + related_name="user_set", + related_query_name="user", + to="auth.group", + verbose_name="groups", + ), + ), + ( + "modified_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="modifications", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "referred_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="referrals", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "user_permissions", + models.ManyToManyField( + blank=True, + help_text="Specific permissions for this user.", + related_name="user_set", + related_query_name="user", + to="auth.permission", + verbose_name="user permissions", + ), + ), + ], + options={ + "db_table": "iam_principal", + }, + ), + migrations.CreateModel( + name="IAmAppResourceActionLink", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "app_action", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="resource_action_link_app_action", + to="accounts.iamappaction", + ), + ), + ( + "app_resource", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="resource_action_link_app_resource", + to="accounts.iamappresource", + ), + ), + ], + options={ + "db_table": "iam_app_resource_action_link", + }, + ), + migrations.AddField( + model_name="iamappresource", + name="action", + field=models.ManyToManyField( + related_name="app_resource_action", + through="accounts.IAmAppResourceActionLink", + to="accounts.iamappaction", + ), + ), + migrations.CreateModel( + name="IAmPrincipalBiometric", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("active", models.BooleanField(default=True)), + ("deleted", models.BooleanField(default=False)), + ("created_on", models.DateTimeField(auto_now_add=True)), + ("modified_on", models.DateTimeField(auto_now=True)), + ("biometric_type", models.CharField(max_length=100)), + ("biometric_data", models.CharField(max_length=255)), + ( + "created_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="%(class)s_created", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "modified_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="%(class)s_modified", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "principal", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="principal_biometric", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "db_table": "iam_principal_biometric", + }, + ), + migrations.CreateModel( + name="IAmPricipalGroupRoleLink", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "principal_group", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="role_link_principal_group", + to="accounts.iamprincipalgroup", + ), + ), + ( + "role", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="role_link_role", + to="accounts.iamrole", + ), + ), + ], + options={ + "db_table": "iam_principal_group_role_link", + }, + ), + migrations.CreateModel( + name="IAmPrincipalGroupLink", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "principal", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="principal_group_link_principal", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "principal_group", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="principal_group_link_group", + to="accounts.iamprincipalgroup", + ), + ), + ], + options={ + "db_table": "iam_principal_principal_group_link", + }, + ), + migrations.AddField( + model_name="iamprincipal", + name="principal_group", + field=models.ManyToManyField( + related_name="principal_groups", + through="accounts.IAmPrincipalGroupLink", + to="accounts.iamprincipalgroup", + ), + ), + migrations.CreateModel( + name="IAmPrincipalLocation", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("active", models.BooleanField(default=True)), + ("deleted", models.BooleanField(default=False)), + ("created_on", models.DateTimeField(auto_now_add=True)), + ("modified_on", models.DateTimeField(auto_now=True)), + ("latitude", models.DecimalField(decimal_places=8, max_digits=14)), + ("longitude", models.DecimalField(decimal_places=8, max_digits=14)), + ( + "created_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="%(class)s_created", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "modified_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="%(class)s_modified", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "principal", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="principal_location", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "db_table": "iam_principal_location", + }, + ), + migrations.CreateModel( + name="IAmPrincipalMerchant", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("active", models.BooleanField(default=True)), + ("deleted", models.BooleanField(default=False)), + ("created_on", models.DateTimeField(auto_now_add=True)), + ("modified_on", models.DateTimeField(auto_now=True)), + ( + "created_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="%(class)s_created", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "modified_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="%(class)s_modified", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "principal", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + related_name="principal_merchant", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "db_table": "iam_principal_merchant", + }, + ), + migrations.CreateModel( + name="IAmPrincipalOtp", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("otp_code", models.CharField(max_length=4)), + ("otp_purpose", models.CharField(blank=True, max_length=50, null=True)), + ("valid_till", models.DateTimeField()), + ("is_used", models.BooleanField(default=False)), + ( + "principal", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="principal_otp", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "db_table": "iam_principal_otp", + }, + ), + migrations.AddField( + model_name="iamprincipal", + name="principal_source", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="principals_source", + to="accounts.iamprincipalsource", + ), + ), + migrations.AddField( + model_name="iamprincipal", + name="principal_type", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="principals_type", + to="accounts.iamprincipaltype", + ), + ), + migrations.AddField( + model_name="iamprincipalgroup", + name="role", + field=models.ManyToManyField( + related_name="principal_group_role", + through="accounts.IAmPricipalGroupRoleLink", + to="accounts.iamrole", + ), + ), + migrations.CreateModel( + name="IAmRoleAppResourceActionLink", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "app_resource_action", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="role_app_resource_action_link_app_resource_action", + to="accounts.iamappresourceactionlink", + ), + ), + ( + "role", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="role_app_resource_action_link_role", + to="accounts.iamrole", + ), + ), + ], + options={ + "db_table": "iam_role_app_resource_action_link", + }, + ), + migrations.AddField( + model_name="iamrole", + name="app_resource_action", + field=models.ManyToManyField( + related_name="role_app_resource_action", + through="accounts.IAmRoleAppResourceActionLink", + to="accounts.iamappresourceactionlink", + ), + ), + ] diff --git a/accounts/migrations/__init__.py b/accounts/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/accounts/models.py b/accounts/models.py new file mode 100644 index 0000000..e419ec1 --- /dev/null +++ b/accounts/models.py @@ -0,0 +1,448 @@ +from collections.abc import Iterable +import datetime +import random +import string + +# from manage_wallets.models import Wallet, Transaction, TransactionStatus, TransactionType +from django.conf import settings +from django.contrib.auth import get_user_model +from django.contrib.auth.models import AbstractUser, BaseUserManager +from django.db import models +from django.utils import timezone +from django.utils.text import slugify +from phonenumber_field.modelfields import PhoneNumberField + +# from manage_subscriptions.models import Subscription +from goodtimes.utils import RandomGenerator +from .resource_action import ( + PRINCIPAL_TYPE_EVENT_USER, + PRINCIPAL_TYPE_EVENT_MANAGER, + PRINCIPAL_TYPE_FREE_USER, +) + +from .utils import UserContext +from django.core.validators import MaxValueValidator, MinValueValidator, RegexValidator + + +class BaseModel(models.Model): + active = models.BooleanField(default=True) + deleted = models.BooleanField(default=False) + created_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + related_name="%(class)s_created", + on_delete=models.CASCADE, + blank=True, + null=True, + ) + created_on = models.DateTimeField(auto_now_add=True) + modified_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + related_name="%(class)s_modified", + on_delete=models.CASCADE, + blank=True, + null=True, + ) + modified_on = models.DateTimeField(auto_now=True) + + class Meta: + abstract = True + + +class MasterModel(models.Model): + name = models.CharField(max_length=255) + label = models.CharField(max_length=255, null=True, blank=True) + slug = models.SlugField(max_length=255, null=True, blank=True) + sort_order = models.IntegerField(blank=True, null=True) + small_image_url = models.ImageField(blank=True, null=True) + large_image_url = models.ImageField(blank=True, null=True) + active = models.BooleanField(default=True) + deleted = models.BooleanField(default=False) + created_by = models.SmallIntegerField(blank=True, null=True) + created_on = models.DateTimeField(auto_now_add=True) + modified_by = models.SmallIntegerField(blank=True, null=True) + modified_on = models.DateTimeField(auto_now=True) + + class Meta: + abstract = True + + def __str__(self): + return f"{self.name}" + + def save(self, *args, **kwargs): + # Generate a slug from the name field + self.slug = slugify(self.name) + return super().save(*args, **kwargs) + + +class IAmPrincipalType(MasterModel): + class Meta: + db_table = "iam_principal_type" + + @classmethod + def get_principal_type(cls, type): + return cls.objects.filter(name=type).first() + + +class IAmPrincipalSource(MasterModel): + class Meta: + db_table = "iam_principal_source" + + +class IAmAppAction(MasterModel): + class Meta: + db_table = "iam_app_action" + + +class IAmAppResource(MasterModel): + action = models.ManyToManyField( + IAmAppAction, + through="IAmAppResourceActionLink", + related_name="app_resource_action", + ) + + class Meta: + db_table = "iam_app_resource" + + +class IAmRoleAppResourceActionLinkManager(models.Manager): + def generate_app_resource_action_data(self): + """ + Generate a dictionary mapping resource names to associated actions. + Returns: + dict: A dictionary with resource names as keys and nested dictionaries + where action IDs are keys and action names are values. + Example: + { + "res1": {1: "a1", 2: "a2"}, + "res2": {3: "a1", 4: "a2"} + } + """ + app_resource_action = self.select_related("app_resource", "app_action").all() + resource_action_link = {} + for item in app_resource_action: + resource = item.app_resource.name + action = item.app_action.name + id = item.id + if resource in resource_action_link: + resource_action_link[resource][id] = action + else: + resource_action_link[resource] = {id: action} + # print(resource_action_link) + return resource_action_link + + +class IAmAppResourceActionLink(models.Model): + app_resource = models.ForeignKey( + IAmAppResource, + related_name="resource_action_link_app_resource", + on_delete=models.CASCADE, + ) + app_action = models.ForeignKey( + IAmAppAction, + related_name="resource_action_link_app_action", + on_delete=models.CASCADE, + ) + objects = IAmRoleAppResourceActionLinkManager() + + class Meta: + db_table = "iam_app_resource_action_link" + + def __str__(self): + return f"{self.app_resource.name}: {self.app_action.name}" + + +class IAmRole(MasterModel): + app_resource_action = models.ManyToManyField( + IAmAppResourceActionLink, + through="IAmRoleAppResourceActionLink", + related_name="role_app_resource_action", + ) + + class Meta: + db_table = "iam_role" + + +class IAmRoleAppResourceActionLink(models.Model): + role = models.ForeignKey( + IAmRole, + related_name="role_app_resource_action_link_role", + on_delete=models.CASCADE, + ) + app_resource_action = models.ForeignKey( + IAmAppResourceActionLink, + related_name="role_app_resource_action_link_app_resource_action", + on_delete=models.CASCADE, + ) + + class Meta: + db_table = "iam_role_app_resource_action_link" + + +class IAmPrincipalGroup(MasterModel): + role = models.ManyToManyField( + IAmRole, through="IAmPricipalGroupRoleLink", related_name="principal_group_role" + ) + + class Meta: + db_table = "iam_principal_group" + + +class IAmPricipalGroupRoleLink(models.Model): + principal_group = models.ForeignKey( + IAmPrincipalGroup, + related_name="role_link_principal_group", + on_delete=models.CASCADE, + ) + role = models.ForeignKey( + IAmRole, related_name="role_link_role", on_delete=models.CASCADE + ) + + class Meta: + db_table = "iam_principal_group_role_link" + + +class IAmPrincipalManager(BaseUserManager): + def create_user(self, email, password=None, **extra_fields): + if not email: + raise ValueError("The Email field must be set") + email = self.normalize_email(email) + user = self.model(email=email, **extra_fields) + user.set_password(password) + user.save(using=self._db) + return user + + def create_superuser(self, email, password=None, **extra_fields): + extra_fields.setdefault("username", email) + extra_fields.setdefault("is_staff", True) + extra_fields.setdefault("is_superuser", True) + # extra_fields.setdefault("phone_no", "+919978895465") + extra_fields.setdefault("gender", "M") + extra_fields.setdefault("referral_code", f"admin_{random.randint(10, 100000)}") + extra_fields.setdefault("date_of_birth", timezone.now()) + extra_fields.setdefault("created_by", None) + extra_fields.setdefault("created_on", timezone.now()) + extra_fields.setdefault("modified_by", None) + extra_fields.setdefault("modified_on", timezone.now()) + return self.create_user(email, password, **extra_fields) + + +class IAmPrincipal(AbstractUser): + principal_type = models.ForeignKey( + IAmPrincipalType, + related_name="principals_type", + null=True, + on_delete=models.PROTECT, + ) + principal_source = models.ForeignKey( + IAmPrincipalSource, + related_name="principals_source", + on_delete=models.CASCADE, + null=True, + ) + email = models.EmailField(unique=True) + gender = models.CharField(max_length=5, blank=True, null=True) + date_of_birth = models.DateField(blank=True, null=True) + phone_no = PhoneNumberField(blank=True, null=True) + address_line1 = models.TextField(blank=True, null=True) + address_line2 = models.TextField(blank=True, null=True) + city = models.CharField(max_length=100, blank=True, null=True) + state = models.CharField(max_length=100, blank=True, null=True) + country = models.CharField(max_length=100, blank=True, null=True) + post_code = models.CharField(max_length=100, blank=True, null=True) + profile_photo = models.ImageField(upload_to="profile", blank=True, null=True) + phone_verified = models.BooleanField(default=False) + email_verified = models.BooleanField(default=False) + referral_code = models.CharField(max_length=50, null=True, blank=True) + referred_by = models.ForeignKey( + "self", + null=True, + blank=True, + related_name="referrals", + on_delete=models.SET_NULL, + ) + created_by = models.ForeignKey( + "self", + null=True, + blank=True, + related_name="creations", + on_delete=models.SET_NULL, + ) + created_on = models.DateTimeField(auto_now_add=True) + modified_by = models.ForeignKey( + "self", + null=True, + blank=True, + related_name="modifications", + on_delete=models.SET_NULL, + ) + modified_on = models.DateTimeField(auto_now=True) + deleted = models.BooleanField(default=False) + principal_group = models.ManyToManyField( + IAmPrincipalGroup, + through="IAmPrincipalGroupLink", + related_name="principal_groups", + ) + register_complete = models.BooleanField(default=False) + + USERNAME_FIELD = "email" + REQUIRED_FIELDS = [] + + objects = IAmPrincipalManager() + + class Meta: + db_table = "iam_principal" + + def __str__(self): + return f"{self.email}" + + +class IAmPrincipalGroupLink(models.Model): + principal = models.ForeignKey( + IAmPrincipal, + related_name="principal_group_link_principal", + on_delete=models.CASCADE, + ) + principal_group = models.ForeignKey( + IAmPrincipalGroup, + related_name="principal_group_link_group", + on_delete=models.CASCADE, + ) + + class Meta: + db_table = "iam_principal_principal_group_link" + + +class IAmPrincipalMerchant(BaseModel): + principal = models.OneToOneField( + IAmPrincipal, related_name="principal_merchant", on_delete=models.CASCADE + ) + + def __str__(self): + return self.principal.email + + class Meta: + db_table = "iam_principal_merchant" + + +class IAmPrincipalOtp(models.Model): + principal = models.ForeignKey( + IAmPrincipal, related_name="principal_otp", on_delete=models.CASCADE + ) + otp_code = models.CharField(max_length=4) + otp_purpose = models.CharField(max_length=50, null=True, blank=True) + valid_till = models.DateTimeField() + is_used = models.BooleanField(default=False) + + class Meta: + db_table = "iam_principal_otp" + + def __str__(self): + return f"{self.principal.phone_no}:{self.otp_code} : {self.otp_purpose}" + + def save(self, *args, **kwargs): + if not self.pk: + self.otp_code = RandomGenerator.random_otp() + self.valid_till = timezone.now() + timezone.timedelta( + minutes=settings.OTP_EXPIRE_TIME + ) + super(IAmPrincipalOtp, self).save(*args, **kwargs) + + def is_expired(self): + return timezone.now() >= self.valid_till + + +class IAmPrincipalBiometric(BaseModel): + principal = models.ForeignKey( + IAmPrincipal, related_name="principal_biometric", on_delete=models.CASCADE + ) + biometric_type = models.CharField(max_length=100) + biometric_data = models.CharField(max_length=255) + + class Meta: + db_table = "iam_principal_biometric" + + def __str__(self): + return f"{self.principal.first_name}:{self.biometric_type}" + + +class IAmPrincipalLocation(BaseModel): + principal = models.ForeignKey( + IAmPrincipal, related_name="principal_location", on_delete=models.CASCADE + ) + latitude = models.DecimalField(max_digits=14, decimal_places=8) + longitude = models.DecimalField(max_digits=14, decimal_places=8) + + class Meta: + db_table = "iam_principal_location" + + def __str__(self): + return f"{self.principal.first_name}:{self.latitude}, {self.longitude}" + + +# Excluded in migrations +# class IAmPrincipalKYCDetails(models.Model): +# # the below is the table structure from Hritik Dhanawde for KYC +# kid = +# status = +# customer_identifier = +# reference_id = +# customer_name = +# reference_id = +# customer_name = +# workflow_name = +# template_id = +# kyc_created_at = +# access_token = + +# # Regex pattern for Aadhar number with exactly 12 digits +# AADHAR_REGEX = r"^\d{12}$" +# # Regex pattern for PAN number in the format AAAAB1234C +# PAN_REGEX = r"^[A-Z]{5}[0-9]{4}[A-Z]$" +# # Regex pattern for IFSC code (11 alphanumeric characters) +# IFSC_REGEX = r"^[A-Za-z]{4}\d{7}$" + +# principal = models.OneToOneField( +# IAmPrincipal, on_delete=models.CASCADE +# ) # Assuming IAmPrincipal is the user model. +# aadhar_front_image = models.ImageField(upload_to="kyc/", blank=True, null=True) +# aadhar_back_image = models.ImageField(upload_to="kyc/", blank=True, null=True) +# aadhar_number = models.CharField( +# max_length=12, +# blank=True, +# null=True, +# validators=[ +# RegexValidator(AADHAR_REGEX, message="Aadhar number must be 12 digits.") +# ], +# ) +# pan_image = models.ImageField(upload_to="kyc/", blank=True, null=True) +# pan_number = models.CharField( +# max_length=10, +# blank=True, +# null=True, +# validators=[ +# RegexValidator( +# PAN_REGEX, message="PAN number must be in the format AAAAB1234C." +# ) +# ], +# ) +# is_aadhar_verified = models.BooleanField(default=False) +# is_pan_verified = models.BooleanField(default=False) +# account_no = models.CharField(max_length=20, blank=True, null=True) +# bank_name = models.CharField(max_length=100, blank=True, null=True) +# branch_name = models.CharField(max_length=100, blank=True, null=True) +# ifsc_code = models.CharField( +# max_length=11, +# blank=True, +# null=True, +# validators=[ +# RegexValidator( +# IFSC_REGEX, message="IFSC code must be 11 alphanumeric characters." +# ) +# ], +# ) + +# class Meta: +# db_table = "iam_principal_kyc_details" + +# def __str__(self): +# return f"KYC Information for {self.principal.email}" diff --git a/accounts/permission.py b/accounts/permission.py new file mode 100644 index 0000000..35adc2b --- /dev/null +++ b/accounts/permission.py @@ -0,0 +1,64 @@ +from functools import wraps +from django.core.exceptions import PermissionDenied +from . import models +from django.db.models import Q +from rest_framework import permissions +# import logging + +# logger = logging.getLogger(__name__) + + +class CustomPermissionRequiredMixin: + resource = None + action = None + + def has_custom_permission(self, user, resource, action): + if not self.resource or not self.action: + raise AttributeError("Resource and action attributes must be defined in the view") + + # if not request.user.is_authenticated: + # return self.handle_no_permission() + + if user.is_superuser: # will chagne to principal type for admin + return True + + permission_query = Q( + principal_group__role__app_resource_action__app_resource__name=resource, + principal_group__role__app_resource_action__app_action__name=action + ) + return models.IAmPrincipal.objects.filter(permission_query, id=user.id).exists() + + def dispatch(self, request, *args, **kwargs): + if not self.has_custom_permission(request.user, self.resource, self.action): + # logger.warning(f"Permission denied for user {request.user} accessing {self.resource}:{self.action}") + raise PermissionDenied("You do not have permission to access this resource.") + return super().dispatch(request, *args, **kwargs) + + @classmethod + def as_decorator(cls, resource, action): + def decorator(view_func): + @wraps(view_func) + def _wrapped_view(request, *args, **kwargs): + instance = cls() + instance.resource = resource + instance.action = action + if not instance.has_custom_permission(request.user, instance.resource, instance.action): + raise PermissionDenied("You do not have permission to access this resource.") + return view_func(request, *args, **kwargs) + return _wrapped_view + return decorator + + +class IsOwnerOrReadOnly(permissions.BasePermission): + """ + Custom permission to only allow owners of an object to edit it. + """ + + def has_object_permission(self, request, view, obj): + # Read permissions are allowed to any request, + # so we'll always allow GET, HEAD or OPTIONS requests. + if request.method in permissions.SAFE_METHODS: + return True + + # Write permissions are only allowed to the owner of the object. + return obj.created_by == request.user diff --git a/accounts/resource_action.py b/accounts/resource_action.py new file mode 100644 index 0000000..73a5a5f --- /dev/null +++ b/accounts/resource_action.py @@ -0,0 +1,34 @@ + +PRINCIPAL_TYPE_EVENT_USER = "event_user" +PRINCIPAL_TYPE_EVENT_MANAGER = "event_manager" +PRINCIPAL_TYPE_FREE_USER = "free_user" +PRINCIPAL_TYPE_ADMIN = "admin" +PRINCIPAL_TYPE_SUBADMIN = "subadmin" + +ACTION_CREATE = "create" +ACTION_READ = "read" +ACTION_UPDATE = "update" +ACTION_DELETE = "delete" + +RESOURCE_MANAGE_DASHBOARD = "manage_dashboard" +RESOURCE_MANAGE_IAM = "manage_iam" +RESOURCE_MANAGE_CUSTOMER = "manage_customer" +RESOURCE_MANAGE_WALLET = "manage_wallet" +RESOURCE_MANAGE_PAYMENT = "manage_payment" +RESOURCE_MANAGE_EVENTS = "manage_events" +RESOURCE_MANAGE_CONTACT_US = "manage_contact_us" +RESOURCE_MANAGE_TICKET = "manage_ticket" +RESOURCE_MANAGE_CMS = "manage_cms" +RESOURCE_MANAGE_REPORTS = "manage_reports" +RESOURCE_MANAGE_SUBSCRIPTIONS = "manage_subscriptions" +RESOURCE_MANAGE_FEEDBACK = "manage_feedback" +RESOURCE_MANAGE_REFERRALS = "manage_referrals" + + +# These constants are used solely for managing the active and inactive state of pages +# and should not be considered as resources in the typical sense. +# They are used for page management purposes only. +RESOURCE_IAM_PRINCIPAL = "iam_principal" +RESOURCE_IAM_PRINCIPAL_GROUP = "iam_principal_group" +RESOURCE_IAM_GROUP = "iam_group" +RESOURCE_IAM_ROLE = "iam_role" diff --git a/accounts/templatetags/custom_permissions.py b/accounts/templatetags/custom_permissions.py new file mode 100644 index 0000000..5c08ee3 --- /dev/null +++ b/accounts/templatetags/custom_permissions.py @@ -0,0 +1,27 @@ +from django import template +from accounts.permission import CustomPermissionRequiredMixin + +register = template.Library() + + +@register.filter(name='has_resource_permission') +def has_resource_permission(user, resource_action): + """ + Check if a user has a specific resource and action permission. + + Args: + user (User): The user to check for permission. + resource_action (str): The resource and action string (e.g., "resource_name.action_name"). + + Returns: + bool: True if the user has the specified permission, False otherwise. + + Example usage in a template: + {% if user|has_resource_permission:"article.add" %} + + {% else %} + + {% endif %} + """ + resource, action = resource_action.split(".") + return CustomPermissionRequiredMixin().has_custom_permission(user, resource, action) diff --git a/accounts/tests.py b/accounts/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/accounts/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/accounts/urls.py b/accounts/urls.py new file mode 100644 index 0000000..f2fd388 --- /dev/null +++ b/accounts/urls.py @@ -0,0 +1,53 @@ +from django.urls import path +from . import views + +from django.views.generic import TemplateView + + +app_name = 'accounts' + +urlpatterns = [ + path('login/', views.AdminLoginView.as_view(), name='login'), + path('logout/', views.AdminLogoutView.as_view(), name='logout'), + path('password-reset/', views.CustomPasswordResetView.as_view(), name='password_reset'), + path('password-reset/done/', views.CustomPasswordResetDoneView.as_view(), name='password_reset_done'), + path('password-reset-confirm///', views.CustomPasswordResetConfirmView.as_view(), name='password_reset_confirm'), + path('password-reset-complete/', views.CustomPasswordResetCompleteView.as_view(), name='password_reset_complete'), + + # path('add/user/', views.PrinicpalCreateView.as_view(), name='register'), + + path('manage-customer/', TemplateView.as_view(template_name='manage_customer/manage_customer.html'), name='manage_customer'), + path('manage-customer/edit/', TemplateView.as_view(template_name='manage_customer/edit_customer.html'), name='edit_customer'), + path('manage-customer/view/', TemplateView.as_view(template_name='manage_customer/view_customer.html'), name='view_customer'), + + path('principal/', views.PrincipalListView.as_view(), name="principal_list"), + path('principal/add/', views.PrincipalCreateOrUpdateView.as_view(), name="principal_add"), + path('principal/edit/', views.PrincipalCreateOrUpdateView.as_view(), name="principal_edit"), + # path('principal/delete/', views.PrincipalDeleteView.as_view(), name="principal_delete"), + + path('principal/group/link/', views.PrincipalGroupLinkListView.as_view(), name="principal_group_link_list"), + path('principal/group/link/edit//', views.PrincipalGroupLinkEditView.as_view(), name="principal_group_link_edit"), + + + path('principal/group/', views.PrincipalGroupListView.as_view(), name="principal_group_list"), + path('principal/group/add/', views.PrincipalGroupCreateOrUpdateView.as_view(), name="principal_group_add"), + path('principal/group/edit//', views.PrincipalGroupCreateOrUpdateView.as_view(), name="principal_group_edit"), + path('principal/group/delete//', views.PrincipalGroupDeleteView.as_view(), name="principal_group_delete"), + + path('principal/role/', views.AppRoleListView.as_view(), name="role_list"), + path('principal/role/add/', views.AppRoleCreateOrUpdateView.as_view(), name="role_add"), + path('principal/role/edit//', views.AppRoleCreateOrUpdateView.as_view(), name="role_edit"), + path('principal/role/delete//', views.AppRoleDeleteView.as_view(), name="role_delete"), + + path('customer/', views.CustomerListView.as_view(), name="customer_list"), + + + # ignore this to path this for example setup + path('principal/example/', TemplateView.as_view(template_name="accounts/iam_module/example_form.html"), name="example_add"), + path('datatable/', views.DatatableListView.as_view(), name="serverside_list"), + # ignore end + + path('profile/', views.PrincipalProfileView.as_view(), name="profile_details"), + path('profile/edit/', views.PrincipalProfileEditView.as_view(), name="profile_details_edit"), + +] diff --git a/accounts/utils.py b/accounts/utils.py new file mode 100644 index 0000000..6542c99 --- /dev/null +++ b/accounts/utils.py @@ -0,0 +1,13 @@ +import threading + + +class UserContext: + _thread_local_data = threading.local() + + @classmethod + def set_user(cls, user): + cls._thread_local_data.current_user = user + + @classmethod + def get_user(cls): + return getattr(cls._thread_local_data, "current_user", None) diff --git a/accounts/views.py b/accounts/views.py new file mode 100644 index 0000000..3962ebe --- /dev/null +++ b/accounts/views.py @@ -0,0 +1,633 @@ +import logging + +from django.contrib import messages +from django.contrib.auth import authenticate, login, logout +from django.contrib.auth.views import LogoutView +from django.contrib.auth.forms import PasswordResetForm +from django.contrib.auth.mixins import LoginRequiredMixin +from django.contrib.auth.views import ( + LoginView, + PasswordResetCompleteView, + PasswordResetConfirmView, + PasswordResetDoneView, + PasswordResetView, +) +from django.core.exceptions import ValidationError +from django.shortcuts import get_object_or_404, redirect, render +from django.urls import reverse_lazy +from django.views import generic +from django.db import models, transaction, IntegrityError + +from goodtimes import constants +from . import resource_action + +from .forms import ( + CustomAuthenticationForm, + IAmPrincipalForm, + IAmPrincipalGroupRoleLinkForm, + IAmPrincipalRoleAppResourceActionLinkForm, + IAmPrincipalGroupLinkForm, + ProfileEditForm, +) +from .models import ( + IAmPrincipal, + IAmPrincipalType, + IAmAppResourceActionLink, + IAmPrincipalGroup, + IAmRole, +) + +logger = logging.getLogger(__name__) + + +class AdminLoginView(generic.View): + form_class = CustomAuthenticationForm + template_name = "accounts/authentication/login.html" + success_url = reverse_lazy("dashboard:main_dashboard") + error_url = reverse_lazy("accounts:login") + success_message = constants.LOGIN_SUCCESS + error_message = "Login failed, Invalid email or password!" + + def get(self, request): + form = self.form_class() + return render(request, self.template_name, context={"form": form}) + + def post(self, request): + form = self.form_class(data=request.POST) + + if not form.is_valid(): + error_message = form.errors.get("__all__") or ["Invalid email or password."] + + messages.error( + request, error_message[0] + ) # Display the form-level error or fallback message + return redirect(self.error_url) + + # Uncomment this block if you implement the first-time login logic + # if not user.last_login: + # messages.info(request, "Welcome! Since this is your first login, please change your password.") + # return redirect(reverse_lazy('accounts:change_password')) + + login(request, form.user) + return redirect(self.success_url) + + +class AdminLogoutView(LogoutView): + next_page = reverse_lazy("accounts:login") + + +class CustomPasswordResetView(PasswordResetView): + form_class = PasswordResetForm + template_name = "accounts/authentication/password_reset_form.html" + email_template_name = "accounts/authentication/password_reset_email_template.html" + success_url = reverse_lazy("accounts:password_reset_done") + + +class CustomPasswordResetDoneView(PasswordResetDoneView): + template_name = "accounts/authentication/password_reset_done.html" + + +class CustomPasswordResetConfirmView(PasswordResetConfirmView): + template_name = "accounts/authentication/password_reset_confirm.html" + success_url = reverse_lazy("accounts:password_reset_complete") + + +class CustomPasswordResetCompleteView(PasswordResetCompleteView): + template_name = "accounts/authentication/password_reset_complete.html" + + +# class PrinicpalCreateView(generic.View): +# model = IAmPrincipal +# form_class = RegistrationForm +# template_name = "registration/form.html" +# title = "Add Sub admin" +# success_message = constants.RECORD_CREATED +# error_message = constants.ERROR_OCCURR +# success_url = reverse_lazy("accounts:register") + +# def get_context_data(self, **kwargs): +# context = {"title": self.title, "operation": "Add"} +# context.update(kwargs) +# return context + +# def get(self, *args, **kwargs): +# form = self.form_class() +# context = self.get_context_data(form=form) +# return render(self.request, self.template_name, context=context) + +# def post(self, *args, **kwargs): +# form = self.form_class(self.request.POST) +# if not form.is_valid(): +# messages.error(self.request, self.error_message) +# context = self.get_context_data(form=form) +# return render(self.request, self.template_name, context=context) +# form.save() +# messages.success(self.request, self.success_message) +# return redirect(self.success_url) + +# template_name = "registration/password_reset_complete.html" + + +class AdminDashboard(generic.View): + template_name = "dashboard/index.html" + + def get(self, request): + return render(request, self.template_name) + + +"""I Am Principal""" + + +class PrincipalListView(LoginRequiredMixin, generic.ListView): + page_name = resource_action.RESOURCE_IAM_PRINCIPAL + # resource = resource_action.RESOURCE_IAM_PRINCIPAL + # action = resource_action.ACTION_READ + model = IAmPrincipal + template_name = "accounts/iam_module/iam_principal_list.html" + context_object_name = "data_obj" + + def get_queryset(self): + return ( + super() + .get_queryset() + .select_related("principal_type", "principal_source") + .exclude( + models.Q( + principal_type__name=resource_action.PRINCIPAL_TYPE_EVENT_MANAGER + ) + | models.Q( + principal_type__name=resource_action.PRINCIPAL_TYPE_EVENT_USER + ) + | models.Q( + principal_type__name=resource_action.PRINCIPAL_TYPE_FREE_USER + ) + ) + ) + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["page_name"] = self.page_name + return context + + +import datetime + + +class PrincipalCreateOrUpdateView(LoginRequiredMixin, generic.View): + page_name = resource_action.RESOURCE_IAM_PRINCIPAL + model = IAmPrincipal + form_class = IAmPrincipalForm + template_name = "accounts/iam_module/iam_principal_add.html" + success_url = reverse_lazy("accounts:principal_list") + success_message = "Saved Successfully" + error_message = "An error occurred while saving the data." + + def get_object(self): + pk = self.kwargs.get("pk") + return get_object_or_404(self.model, pk=pk) if pk else None + + def get_context_data(self, **kwargs): + context = { + "page_name": self.page_name, + "operation": "Edit" if self.object else "Add", + } + context.update(kwargs) # Include any additional context data passed to the view + return context + + def get(self, request, *args, **kwargs): + self.object = self.get_object() + form = self.form_class(instance=self.object) + context = self.get_context_data(form=form) + return render(request, self.template_name, context=context) + + @transaction.atomic + def post(self, request, *args, **kwargs): + print(request.POST) + self.object = self.get_object() + form = self.form_class(request.POST, instance=self.object) + try: + if form.is_valid(): + principal = form.save(commit=False) + + # Check if it's a new object (create action) or an existing one (update action) + if not principal.pk: # pk is None for new objects + principal.created_by = request.user + principal.modified_by = request.user + principal.modified_on = datetime.datetime.now() + + # Save the object + principal.save() + + messages.success(request, "Form submitted successfully") + return redirect(self.success_url) + except Exception as e: + self.error_message = constants.ERROR_OCCURR.format(str(e)) + print(self.error_message) + messages.error(request, self.error_message) + + context = self.get_context_data(form=form) + return render(request, template_name=self.template_name, context=context) + + +"""Principal Group Link""" + + +class PrincipalGroupLinkListView(LoginRequiredMixin, generic.ListView): + page_name = resource_action.RESOURCE_IAM_PRINCIPAL_GROUP + model = IAmPrincipal + template_name = "accounts/iam_module/iam_principal_group_link_list.html" + context_object_name = "data_obj" + + def get_queryset(self): + return ( + super() + .get_queryset() + .select_related("principal_type", "principal_source") + .prefetch_related("principal_group") + ) + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["page_name"] = self.page_name + context["admin_principal"] = self.get_queryset().filter( + principal_type__name=resource_action.PRINCIPAL_TYPE_ADMIN, is_active=True + ) + context["subadmin_principal"] = self.get_queryset().filter( + principal_type__name=resource_action.PRINCIPAL_TYPE_SUBADMIN, is_active=True + ) + print(context["subadmin_principal"]) + return context + + +class PrincipalGroupLinkEditView(LoginRequiredMixin, generic.View): + page_name = resource_action.RESOURCE_IAM_PRINCIPAL_GROUP + model = IAmPrincipal + template_name = "accounts/iam_module/iam_principal_group_link_edit.html" + form_class = IAmPrincipalGroupLinkForm + success_url = reverse_lazy("accounts:principal_group_link_list") + success_message = "Record Updated Successfully" + error_message = "An error occurred while saving the data" + + def get_object(self): + pk = self.kwargs.get("pk") + return get_object_or_404(self.model, pk=pk) if pk else None + + def get_context_data(self, **kwargs): + context = { + "page_name": self.page_name, + "operation": "Edit", + } + context.update(kwargs) # Include any additional context data passed to the view + return context + + def get(self, request, *args, **kwargs): + self.object = self.get_object() + form = self.form_class(instance=self.object) + context = self.get_context_data(form=form) + return render(request, self.template_name, context=context) + + def post(self, request, *args, **kwargs): + self.object = self.get_object() + form = self.form_class(request.POST, instance=self.object) + if not form.is_valid(): + context = self.get_context_data(form=form) + return render(request, self.template_name, context=context) + form.save() + messages.success(request, self.success_message) + return redirect(self.success_url) + + +"""Principal Group""" + + +class PrincipalGroupListView(LoginRequiredMixin, generic.ListView): + page_name = resource_action.RESOURCE_IAM_GROUP + model = IAmPrincipalGroup + template_name = "accounts/iam_module/iam_group_list.html" + context_object_name = "data_obj" + + def get_queryset(self): + return super().get_queryset().prefetch_related("role").filter(deleted=False) + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["page_name"] = self.page_name + return context + + +class PrincipalGroupCreateOrUpdateView(LoginRequiredMixin, generic.View): + page_name = resource_action.RESOURCE_IAM_GROUP + page_title = "Principal Group" + model = IAmPrincipalGroup + template_name = "accounts/iam_module/iam_group_add.html" + form_class = IAmPrincipalGroupRoleLinkForm + success_url = reverse_lazy("accounts:principal_group_list") + error_message = "An error occurred while saving the data." + + def get_success_message(self): + self.success_message = ( + constants.RECORD_CREATED if not self.object else constants.RECORD_UPDATED + ) + return self.success_message + + def get_object(self): + pk = self.kwargs.get("pk") + return get_object_or_404(self.model, pk=pk) if pk else None + + def get_context_data(self, **kwargs): + context = { + "page_name": self.page_name, + "operation": "Add" if not self.object else "Edit", + } + context.update(kwargs) # Include any additional context data passed to the view + return context + + def get(self, request, *args, **kwargs): + self.object = self.get_object() + form = self.form_class(instance=self.object) + context = self.get_context_data(form=form) + return render(request, self.template_name, context=context) + + def post(self, request, *args, **kwargs): + self.object = self.get_object() + form = self.form_class(request.POST, instance=self.object) + if not form.is_valid(): + print(form.errors) + context = self.get_context_data(form=form) + return render(request, self.template_name, context=context) + form.save() + messages.success(self.request, self.get_success_message()) + return redirect(self.success_url) + + +class PrincipalGroupDeleteView(LoginRequiredMixin, generic.View): + page_name = resource_action.RESOURCE_IAM_GROUP + model = IAmPrincipalGroup + success_url = reverse_lazy("accounts:principal_group_list") + success_message = constants.RECORD_DELETED + error_message = constants.RECORD_NOT_FOUND + + def get(self, request, pk): + try: + type_obj = self.model.objects.get(id=pk) + principal = IAmPrincipal.objects.filter(principal_group=type_obj).exists() + if principal: + messages.success( + request, + "You can't delete this record as it's assigned to principals.", + ) + else: + type_obj.deleted = True + type_obj.save() + messages.success(request, self.success_message) + except self.model.DoesNotExist: + messages.success(request, self.error_message) + + return redirect(self.success_url) + + +""" Role""" + + +class AppRoleListView(LoginRequiredMixin, generic.ListView): + page_name = resource_action.RESOURCE_IAM_ROLE + model = IAmRole + template_name = "accounts/iam_module/iam_role_list.html" + context_object_name = "data_obj" + + def get_queryset(self): + return ( + super() + .get_queryset() + .prefetch_related( + "app_resource_action", + "app_resource_action__app_resource", + "app_resource_action__app_action", + ) + .filter(deleted=False) + ) + + def generate_role_data(self): + roles = self.get_queryset() + role_data = [] + for role in roles: + role_info = { + "id": role.id, + "name": role.name, + "active": role.active, + "resources": {}, + } + + for link in role.app_resource_action.all(): + resource = link.app_resource.name + action = link.app_action.name + if resource in role_info["resources"]: + role_info["resources"][resource].append(action) + else: + role_info["resources"][resource] = [action] + role_data.append(role_info) + + return role_data + + def get_context_data(self, **kwargs): + context = {"page_name": self.page_name, "roles": self.generate_role_data()} + context.update(kwargs) + return context + + +class AppRoleCreateOrUpdateView(LoginRequiredMixin, generic.View): + page_name = resource_action.RESOURCE_IAM_ROLE + model = IAmRole + template_name = "accounts/iam_module/iam_role_add.html" + form_class = IAmPrincipalRoleAppResourceActionLinkForm + success_url = reverse_lazy("accounts:role_list") + success_message = "Saved Successfully" + error_message = "An error occurred while saving the data." + + def get_success_message(self): + self.success_message = ( + f"Record {'Created' if not self.object else 'Updated'} Successfully" + ) + return self.success_message + + def get_object(self): + pk = self.kwargs.get("pk") + return get_object_or_404(self.model, pk=pk) if pk else None + + def get_context_data(self, **kwargs): + context = { + "page_name": self.page_name, + "operation": "Add" if not self.object else "Edit", + "app_resource_action": IAmAppResourceActionLink.objects.generate_app_resource_action_data(), + } + context.update(kwargs) # Include any additional context data passed to the view + return context + + def get(self, request, *args, **kwargs): + try: + self.object = self.get_object() + form = self.form_class(instance=self.object) + context = self.get_context_data(form=form) + return render(request, self.template_name, context=context) + except Exception as e: + messages.error(request, str(e)) + return redirect(self.success_url) + + def post(self, request, *args, **kwargs): + try: + self.object = self.get_object() + form = self.form_class(request.POST, instance=self.object) + if not form.is_valid(): + print(form.errors) + context = self.get_context_data(form=form) + return render(request, self.template_name, context=context) + form.save() + messages.success(self.request, self.get_success_message()) + return redirect(self.success_url) + except Exception as e: + messages.error(self.request, str(e)) + return redirect(self.success_url) + + +class AppRoleDeleteView(LoginRequiredMixin, generic.View): + page_name = resource_action.RESOURCE_IAM_ROLE + model = IAmRole + success_url = reverse_lazy("accounts:role_list") + success_message = constants.RECORD_DELETED + error_message = constants.RECORD_NOT_FOUND + + def get(self, request, pk): + try: + type_obj = self.model.objects.get(id=pk) + principal = IAmPrincipalGroup.objects.filter(role=type_obj).exists() + if principal: + messages.success( + request, "You can't delete this record as it's assigned to groups." + ) + else: + type_obj.deleted = True + type_obj.save() + messages.success(request, self.success_message) + except self.model.DoesNotExist: + messages.success(request, self.error_message) + + return redirect(self.success_url) + + +"""Customer""" + + +class CustomerListView(LoginRequiredMixin, generic.ListView): + page_name = resource_action.RESOURCE_MANAGE_CUSTOMER + resource = resource_action.RESOURCE_MANAGE_CUSTOMER + action = resource_action.ACTION_READ + model = IAmPrincipal + template_name = "accounts/customer/customer_list.html" + context_object_name = "data_obj" + + def get_queryset(self): + user_types = [ + resource_action.PRINCIPAL_TYPE_EVENT_USER, + resource_action.PRINCIPAL_TYPE_EVENT_MANAGER, + resource_action.PRINCIPAL_TYPE_FREE_USER, + ] + + return ( + super() + .get_queryset() + .select_related("principal_type", "principal_source") + .filter( + models.Q( + principal_type__name=resource_action.PRINCIPAL_TYPE_EVENT_MANAGER + ) + | models.Q( + principal_type__name=resource_action.PRINCIPAL_TYPE_EVENT_USER + ) + | models.Q( + principal_type__name=resource_action.PRINCIPAL_TYPE_FREE_USER + ), + # principal_type__name=resource_action.PRINCIPAL_TYPE_PLAYER, + # principal_type__in=user_types, + deleted=False, + ) + ) + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["page_name"] = self.page_name + return context + + +class DatatableListView(LoginRequiredMixin, generic.ListView): + pass + + +class PrincipalProfileView(LoginRequiredMixin, generic.ListView): + page_name = resource_action.RESOURCE_MANAGE_DASHBOARD + model = IAmPrincipal + template_name = "accounts/iam_module/profile_details.html" + context_object_name = "data_obj" + + def get_queryset(self): + return ( + super() + .get_queryset() + .select_related("principal_type", "principal_source") + .get(id=self.request.user.id) + ) + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["page_name"] = self.page_name + return context + + +class PrincipalProfileEditView(LoginRequiredMixin, generic.View): + page_name = resource_action.RESOURCE_MANAGE_DASHBOARD + model = IAmPrincipal + template_name = "accounts/iam_module/profile_details_edit.html" + form_class = ProfileEditForm + success_url = reverse_lazy("accounts:profile_details") + success_message = "Saved Successfully" + error_message = "An error occurred while saving the data." + + def get_success_message(self): + self.success_message = ( + f"Record {'Created' if not self.object else 'Updated'} Successfully" + ) + return self.success_message + + def get_object(self): + return self.request.user + + def get_context_data(self, **kwargs): + context = { + # "page_name": self.page_name, + "operation": "Edit", + "page_name": self.page_name, + } + context.update(kwargs) # Include any additional context data passed to the view + return context + + def get(self, request, *args, **kwargs): + # try: + self.object = self.get_object() + form = self.form_class(instance=self.object) + context = self.get_context_data(form=form) + return render(request, self.template_name, context=context) + # except Exception as e: + # print("error in project ", str) + # messages.error(request, str(e)) + # return redirect(self.success_url) + + def post(self, request, *args, **kwargs): + self.object = self.get_object() + form = self.form_class(request.POST, request.FILES, instance=self.object) + if not form.is_valid(): + print(form.errors) + context = self.get_context_data(form=form) + return render(request, self.template_name, context=context) + + form.save() + messages.success(self.request, self.get_success_message()) + return redirect(self.success_url) diff --git a/chat/__init__.py b/chat/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/chat/admin.py b/chat/admin.py new file mode 100644 index 0000000..e3924f5 --- /dev/null +++ b/chat/admin.py @@ -0,0 +1,15 @@ +from django.contrib import admin +from . models import ChatGroup, ChatMessage +# Register your models here. +class ChatMessageAdmin(admin.ModelAdmin): + list_display = ('id', 'user', 'group', 'message', 'timestamp') + + +admin.site.register(ChatMessage, ChatMessageAdmin) + + +class ChatGroupAdmin(admin.ModelAdmin): + list_display = ('id', 'name') + + +admin.site.register(ChatGroup, ChatGroupAdmin) diff --git a/chat/api/serializers.py b/chat/api/serializers.py new file mode 100644 index 0000000..9fbe054 --- /dev/null +++ b/chat/api/serializers.py @@ -0,0 +1,23 @@ +from rest_framework import serializers +from accounts.models import IAmPrincipal +from goodtimes import constants +from chat import models +from accounts.api.serializers import ProfilePhotoSerializer + + +class ChatGroupSerializer(serializers.ModelSerializer): + class Meta: + model = models.ChatGroup + fields = ("id", "name") + + +class ChatMessageSerializer(serializers.ModelSerializer): + user = ProfilePhotoSerializer() + + class Meta: + model = models.ChatMessage + fields = ("id", "group", "message", "timestamp", "user") + + +class TeamCheckSerializer(serializers.Serializer): + has_team = serializers.BooleanField() diff --git a/chat/api/urls.py b/chat/api/urls.py new file mode 100644 index 0000000..89f3d0d --- /dev/null +++ b/chat/api/urls.py @@ -0,0 +1,17 @@ +from django.urls import path +from . import views + +app_name = "chat_api" + +urlpatterns = [ + path("/", views.EnterRoomApi.as_view(), name="enter_room"), + path( + "chat_group//", views.ChatGroupAPIView.as_view(), name="chat_group" + ), + path( + "chat_messages//", + views.ChatMessageAPIView.as_view(), + name="chat_messages", + ), + # path('team_check//', views.TeamCheckAPIView.as_view(), name='team_check'), +] diff --git a/chat/api/views.py b/chat/api/views.py new file mode 100644 index 0000000..f779f3c --- /dev/null +++ b/chat/api/views.py @@ -0,0 +1,108 @@ +from django.shortcuts import get_object_or_404 +from rest_framework import status +from rest_framework.response import Response +from rest_framework.views import APIView +from accounts.models import IAmPrincipal +from goodtimes import services, constants +from rest_framework import generics, pagination +from chat import models +# from stock.models import Team +from django.conf import settings +from . import serializers +from goodtimes.utils import ApiResponse +from rest_framework.permissions import AllowAny, IsAuthenticated +from rest_framework_simplejwt.authentication import JWTAuthentication + + +class EnterRoomApi(APIView): + authentication_classes = [JWTAuthentication] + permission_classes = [IsAuthenticated] + + def post(self, request, room_name): + principal_id = request.user.id + + return Response({"room_name": room_name}) + + +class ChatGroupAPIView(APIView): + authentication_classes = [JWTAuthentication] + permission_classes = [IsAuthenticated] + + def get(self, request, game_id): + group_name = f"game_{game_id}" + chat_group, created = models.ChatGroup.objects.get_or_create(name=group_name) + + serializer = serializers.ChatGroupSerializer(chat_group) + response_data = { + "status": status.HTTP_200_OK, + "message": constants.SUCCESS, + "data": serializer.data, + } + return ApiResponse.success(**response_data) + + +class CustomPagination(pagination.PageNumberPagination): + page_size = 30 + page_size_query_param = "page_size" + max_page_size = 100 + + +class ChatMessageAPIView(generics.ListAPIView): + serializer_class = serializers.ChatMessageSerializer + pagination_class = CustomPagination + + def get_queryset(self): + game_name = f"game_{self.kwargs['game_name']}" + return models.ChatMessage.objects.filter(group__name=game_name).order_by( + "-timestamp" + ) + + def get(self, request, *args, **kwargs): + queryset = self.get_queryset() + page = self.paginate_queryset(queryset) + + if page is not None: + serializer = self.get_serializer(page, many=True) + response_data = { + "status": status.HTTP_200_OK, + "message": constants.SUCCESS, + "data": serializer.data, + } + return ApiResponse.success(**response_data) + response_empty = { + "status": status.HTTP_200_OK, + "message": constants.SUCCESS, + "data": serializer.data, + } + return ApiResponse.success(**response_empty) + + +# class TeamCheckAPIView(generics.RetrieveAPIView): +# serializer_class = serializers.TeamCheckSerializer + +# def get(self, request, *args, **kwargs): +# user = request.user +# game_id = kwargs.get("game_id") + +# if not user or not game_id: +# response_error = { +# "status": status.HTTP_404_NOT_FOUND, +# "message": constants.FAILURE, +# "errors": "User and Game ID are required parameters.", +# } +# return ApiResponse.error(**response_error) + +# teams = Team.objects.filter(game_id=game_id, principal=request.user) + +# has_team = teams.exists() + +# data = {"has_team": has_team} +# serializer = serializers.TeamCheckSerializer(data=data) +# serializer.is_valid(raise_exception=True) # Ensure serializer is valid + +# response_data = { +# "status": status.HTTP_200_OK, +# "message": constants.SUCCESS, +# "data": serializer.validated_data, +# } +# return ApiResponse.success(**response_data) diff --git a/chat/apps.py b/chat/apps.py new file mode 100644 index 0000000..5f75238 --- /dev/null +++ b/chat/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class ChatConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "chat" diff --git a/chat/consumers.py b/chat/consumers.py new file mode 100644 index 0000000..9bdbf11 --- /dev/null +++ b/chat/consumers.py @@ -0,0 +1,127 @@ +from channels.generic.websocket import AsyncWebsocketConsumer, WebsocketConsumer +import json +import django +django.setup() + +from accounts.models import IAmPrincipal +from channels.exceptions import StopConsumer +from asgiref.sync import async_to_sync, sync_to_async +from django.utils import timezone +from chat.models import ChatGroup, ChatMessage +from channels.db import database_sync_to_async +from django.db import close_old_connections +from rest_framework_simplejwt.tokens import AccessToken +from rest_framework_simplejwt.authentication import JWTAuthentication +import threading + + +class ChatConsumer(AsyncWebsocketConsumer): + async def connect(self): + print("self.scope: ", self.scope) + self.room_name = ( + self.scope.get("url_route", {}).get("kwargs", {}).get("room_name") + ) + print("self.room_name: ", self.room_name) + token_key = self.scope["url_route"]["kwargs"]["user"] + print("token_key: ", token_key) + self.user = await self.get_user_async(token_key) + print("self.user: ", self.user) + # Start the thread to get the user object + # user_thread = threading.Thread(target=self.get_user_async, args=(token_key,)) + # user_thread.start() + + # # Wait for the thread to finish and assign the user object to the scope + # user_thread.join() + # self.scope["user"] = self.user + # print("User: ", self.scope["user"]) + # print("self.user: ", self.user) + + # Join room group + await self.channel_layer.group_add(self.room_name, self.channel_name) + print("Time: ", timezone.now()) + await self.accept() + + async def disconnect(self, close_code): + # Leave room group + print("Time: ", timezone.now()) + await self.channel_layer.group_discard(self.room_name, self.channel_name) + raise StopConsumer() + # await self.disconnect(close_code) + + # Receive message from WebSocket + async def receive(self, text_data): + print("text_data: ", text_data) + print("self.user: ", self.user) + try: + text_data_json = json.loads(text_data) + message = text_data_json["message"] + except json.JSONDecodeError: + # Handle non-JSON message + message = text_data + print("message: ", message) + group = await self.get_chat_group(self.room_name) + print("group: ", group) + await self.create_chat_message(group, message, self.user) + # await self.create_chat_message(group, text_data_json, user) + + # Send message to room group + await self.channel_layer.group_send( + # self.room_name, {"type": "chat.message", "message": message} + self.room_name, + { + "type": "chat.message", + "message": message, + "timestamp": str(timezone.now()), + "user": self.user.email, + "first_name": self.user.first_name, + "profile_photo": self.user.profile_photo.url + if self.user.profile_photo and self.user.profile_photo.url + else None, + }, + ) + + @database_sync_to_async + def create_chat_message(self, room_name, message, user): + # principal = IAmPrincipal.objects.get(id=self.user) + return ChatMessage.objects.create( + group=room_name, + timestamp=timezone.now(), + message=message, + user=self.user, + ) + + @database_sync_to_async + def get_chat_group(self, room_name): + return ChatGroup.objects.filter(name=room_name).first() + + # Receive message from room group + async def chat_message(self, event): + print("event: ", event) + message = event["message"] + user = event["user"] + first_name = event["first_name"] + profile_photo = event["profile_photo"] + timestamp = event["timestamp"] + # new_message = str(user) + " :- " + message + # Send message to WebSocket + await self.send( + text_data=json.dumps( + { + "message": message, + "user": user, + "first_name": first_name, + "profile_photo": profile_photo, + "timestamp": timestamp, + } + ) + ) + + @database_sync_to_async + def get_user_async(self, token): + try: + decoded_token = JWTAuthentication().get_validated_token(token) + user = JWTAuthentication().get_user(decoded_token) + self.user = user + return user + except Exception as e: + self.user = None diff --git a/chat/migrations/0001_initial.py b/chat/migrations/0001_initial.py new file mode 100644 index 0000000..b28ac40 --- /dev/null +++ b/chat/migrations/0001_initial.py @@ -0,0 +1,62 @@ +# Generated by Django 5.0.2 on 2024-02-29 07:47 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="ChatGroup", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=255, unique=True)), + ], + ), + migrations.CreateModel( + name="ChatMessage", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("message", models.TextField()), + ("timestamp", models.DateTimeField(auto_now_add=True)), + ( + "group", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to="chat.chatgroup" + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="PrincipalChat", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + ), + ] diff --git a/chat/migrations/__init__.py b/chat/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/chat/models.py b/chat/models.py new file mode 100644 index 0000000..297f002 --- /dev/null +++ b/chat/models.py @@ -0,0 +1,31 @@ +from django.db import models +from accounts.models import IAmPrincipal + + +# Create your models here. +class ChatGroupManager(models.Manager): + def get_or_create_group(self, game_id): + group_name = f"game_{game_id}" + chat_group, created = self.get_or_create(name=group_name) + return chat_group + + +class ChatGroup(models.Model): + name = models.CharField(max_length=255, unique=True) + + objects = ChatGroupManager() + + def __str__(self): + return self.name + + +class ChatMessage(models.Model): + group = models.ForeignKey(ChatGroup, on_delete=models.CASCADE) + message = models.TextField() + timestamp = models.DateTimeField(auto_now_add=True) + user = models.ForeignKey( + IAmPrincipal, related_name="PrincipalChat", on_delete=models.CASCADE + ) + + def __str__(self): + return self.group diff --git a/chat/routing.py b/chat/routing.py new file mode 100644 index 0000000..a565b78 --- /dev/null +++ b/chat/routing.py @@ -0,0 +1,8 @@ +from django.urls import re_path, path + +from . import consumers + +websocket_urlpatterns = [ + path("ws/chat//", consumers.ChatConsumer.as_asgi()), + # path("ws/chat/", consumers.ChatConsumer.as_asgi()), +] diff --git a/chat/tests.py b/chat/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/chat/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/chat/urls.py b/chat/urls.py new file mode 100644 index 0000000..7a01061 --- /dev/null +++ b/chat/urls.py @@ -0,0 +1,9 @@ +from django.urls import path +from . import views + +app_name = "chat" + +urlpatterns = [ + + +] \ No newline at end of file diff --git a/chat/views.py b/chat/views.py new file mode 100644 index 0000000..91ea44a --- /dev/null +++ b/chat/views.py @@ -0,0 +1,3 @@ +from django.shortcuts import render + +# Create your views here. diff --git a/dashboard/__init__.py b/dashboard/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/dashboard/admin.py b/dashboard/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/dashboard/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/dashboard/apps.py b/dashboard/apps.py new file mode 100644 index 0000000..7b1cc05 --- /dev/null +++ b/dashboard/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class DashboardConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'dashboard' diff --git a/dashboard/migrations/__init__.py b/dashboard/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/dashboard/models.py b/dashboard/models.py new file mode 100644 index 0000000..71a8362 --- /dev/null +++ b/dashboard/models.py @@ -0,0 +1,3 @@ +from django.db import models + +# Create your models here. diff --git a/dashboard/tests.py b/dashboard/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/dashboard/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/dashboard/urls.py b/dashboard/urls.py new file mode 100644 index 0000000..2164bce --- /dev/null +++ b/dashboard/urls.py @@ -0,0 +1,12 @@ +from django.urls import path +from . import views +from django.views.generic import TemplateView +from accounts import resource_action + + +app_name = 'dashboard' + +urlpatterns = [ + path('main-dashboard/', views.DashboardView.as_view(), name='main_dashboard'), + +] diff --git a/dashboard/views.py b/dashboard/views.py new file mode 100644 index 0000000..8a34f1d --- /dev/null +++ b/dashboard/views.py @@ -0,0 +1,16 @@ +from django.shortcuts import render +from django.contrib.auth.mixins import LoginRequiredMixin +from accounts import resource_action +from django.views import generic + +# Create your views here. + + +class DashboardView(LoginRequiredMixin, generic.TemplateView): + page_name = resource_action.RESOURCE_MANAGE_DASHBOARD + template_name = "dashboard/main-dashboard.html" + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["page_name"] = self.page_name + return context diff --git a/goodtimes/__init__.py b/goodtimes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/goodtimes/asgi.py b/goodtimes/asgi.py new file mode 100644 index 0000000..26374ad --- /dev/null +++ b/goodtimes/asgi.py @@ -0,0 +1,16 @@ +""" +ASGI config for goodtimes project. + +It exposes the ASGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/5.0/howto/deployment/asgi/ +""" + +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "goodtimes.settings") + +application = get_asgi_application() diff --git a/goodtimes/constants.py b/goodtimes/constants.py new file mode 100644 index 0000000..2aa0665 --- /dev/null +++ b/goodtimes/constants.py @@ -0,0 +1,71 @@ +VALIDATION_ERROR = "Validation Error" + +# CRUD Related Constants +SUCCESS = "Operation successful." +FAILURE = "Operation failed." +RECORD_CREATED = "Record created successfully." +RECORD_UPDATED = "Record updated successfully." +RECORD_NOT_FOUND = "Record not found." +RECORD_DELETED = "Record deleted successfully." +ERROR_OCCURR = "An error occurred: {}" +SOMETHING_WRONG = "Something went wrong" +DATA_SAVED = "Data saved successfully." +DATA_UPDATED = "Data updated successfully." +DATA_DELETED = "Data deleted successfully." +DATA_IMPORT_SUCCESS = "Data import successful." +DATA_EXPORT_SUCCESS = "Data export successful." +DATA_INTEGRITY_ERROR = "Data integrity error. Please contact support." +INTERNAL_SERVER_ERROR = "Internal server error" + +# File Related Constants +FILE_NOT_FOUND = "The requested file was not found." +FILE_UPLOAD_ERROR = "An error occurred while uploading files." +FILE_UPLOAD_SUCCESS = "Files uploaded successfully." + +# Registration and Authentication Related Constants +REGISTRATION_INCOMPLETE = "Registration incomplete." +REGISTRATION_SUCCESS = "Registration successful." +REGISTRATION_FAIL = "Registration failed." +LOGIN_REQUIRED = "Login required to perform this action." +LOGIN_SUCCESS = "Login successful." +LOGIN_FAIL = "Login failed." +LOGOUT_SUCCESS = "Logout successful." +SESSION_EXPIRED = "Your session has expired. Please log in again." +ACCOUNT_DEACTIVATED = "Your account is inactive. Please contact support." +EMAIL_EXISTS = "This email address is already in use. Please use a different email." +INVALID_EMAIL_PASSWORD = "Invalid email or password." +INVALID_PASSWORD = "Invalid password." +INVALID_OPERATION = "Invalid operation requested." +PASSWORD_RESET_SUCCESS = "Password reset successful. You can now log in with your new password." +EMAIL_VERIFICATION_SUCCESS = "Email verification successful. You can now log in." +PASSWORD_CHANGE_SUCCESS = "Password change successful. Your password has been updated." +PASSWORD_CHANGE_FAILURE = "Password change failed. Please try again later." + + +# Mobile OTP Related Constants +PHONE_NUMBER_EXISTS = "This phone number is already in use." +EMAIL_EXISTS = "This email is already in use." +PHONE_NUMBER_NOT_REGISTERED = "This phone number is not registered." +EMAIL_NOT_REGISTERED = "This phone number is not registered." +PHONE_NUMBER_NOT_FOUND = "Phone number not found." +PHONE_FIELD_IS_REQUIRED = "Phone field is required." +PHONE_NUMBER_INVALID = 'Invalid phone number.' +PHONE_NUMBER_VERIFICATION_SUCCESS = "Phone number verification successful." +PHONE_NUMBER_VERIFICATION_FAILED = "Phone number verification failed." +OTP_INVALID = "Invalid OTP." +OTP_VERIFIED = "OTP verified successful" +OTP_SENT = "OTP sent successfully." +OTP_FIELD_IS_REQUIRED = "OTP is required." +OTP_OR_PASSWORD_REQUIRED = "OTP or Password is required" +OTP_EXPIRED = "OTP has expired." + +# Email Related Constants +EMAIL_SENT = "Email sent successfully." +EMAIL_NOT_SENT = "Email could not be sent. Please try again later." + +# Payment and Transaction Related Constants +PAYMENT_SUCCESS = "Payment successful. Thank you for your purchase!" +PAYMENT_FAILED = "Payment failed. Please check your payment details and try again." +TRANSACTION_PENDING = "Your transaction is currently pending processing." +WITHDRAWAL_FAILED = "Withdraw failed. Please check your wallet details." +WITHDRAWAL_SUCCESS = "Withdraw successful. Happy Earnings!" diff --git a/goodtimes/date_utils.py b/goodtimes/date_utils.py new file mode 100644 index 0000000..54fbec1 --- /dev/null +++ b/goodtimes/date_utils.py @@ -0,0 +1,64 @@ +from datetime import datetime, timedelta + +# Format date +def format_date_to_string(date, format='%d %b, %Y'): + return date.strftime(format) + +# Format date or time +def format_datetime_to_sting(dt, format='%d %b, %Y %H:%M:%S'): + return dt.strftime(format) + +# Get current date +def get_current_date(): + return datetime.now() + +# Get current time +def get_current_time(): + return datetime.now().time() + +# Get current date in a specific timezone +from pytz import timezone +def get_current_date_in_timezone(timezone_str='UTC'): + tz = timezone(timezone_str) + return datetime.now(tz) + +# Convert string to datetime +def string_to_date(date_string, format='%Y-%m-%d'): + return datetime.strptime(date_string, format) + +# Convert string to datetime +def string_to_datetime(datetime_string, format='%Y-%m-%d %H:%M:%S'): + return datetime.strptime(datetime_string, format) + +# Get difference between two dates +def get_date_difference(date1, date2): + return abs(date2 - date1) + +# Get difference between two datetimes +def get_datetime_difference(datetime1, datetime2): + return abs(datetime2 - datetime1) + +# Add days to a given date +def add_days_to_date(date, days): + return date + timedelta(days=days) + +# Subtract days from a given date +def subtract_days_from_date(date, days): + return date - timedelta(days=days) + +# Check if a year is a leap year +def is_leap_year(year): + return (year % 4 == 0 and year % 100 != 0) or (year % 400 == 0) + +# Get the last day of a given month and year +def last_day_of_month(year, month): + if month == 12: + year += 1 + month = 1 + else: + month += 1 + return datetime(year, month, 1) - timedelta(days=1) + +# Check if a date is within a specific range +def is_date_within_range(date, start_date, end_date): + return start_date <= date <= end_date diff --git a/goodtimes/renderers.py b/goodtimes/renderers.py new file mode 100644 index 0000000..242995f --- /dev/null +++ b/goodtimes/renderers.py @@ -0,0 +1,15 @@ +from rest_framework import renderers +import json + + +class UserRenderer(renderers.JSONRenderer): + charset = "utf-8" + + def render(self, data, accepted_media_type=None, renderer_context=None): + response = "" + if "ErrorDetail" in str(data): + response = json.dumps({"errors": data}) + else: + response = json.dumps(data) + + return response diff --git a/goodtimes/services.py b/goodtimes/services.py new file mode 100644 index 0000000..c03d57c --- /dev/null +++ b/goodtimes/services.py @@ -0,0 +1,849 @@ +import random +from django.conf import settings +from django.core.files.uploadedfile import UploadedFile +from django.core.mail import EmailMessage +from django.utils.html import strip_tags +from django.template.loader import render_to_string +from django.shortcuts import get_object_or_404 +from smtplib import SMTPException +from accounts.models import IAmPrincipal, IAmPrincipalOtp, IAmPrincipalType +from manage_wallets.models import Wallet, Transaction +from goodtimes.utils import CapacityError, RandomGenerator +from manage_events.models import Event, EventPrincipalInteraction + +# from twilio.rest import Client +from django.db.models import Q, Count +import phonenumbers +from decimal import Decimal +from django.db.models import Subquery, OuterRef, F, Sum, Window +from django.db import transaction +from datetime import timedelta, time, datetime +from django.utils import timezone +import logging + + +logger = logging.getLogger(__name__) + + +class EmailService: + email = None + body = None + subject = None + to = None + from_email = None + + content_subtype = "html" + + def __init__(self, subject=None, to=None, from_email=None): + self.subject = subject + # self.to = (to,) + self.to = to if isinstance(to, list) else [to] + self.from_email = from_email + + def set_to(self, to): + # self.to = to + self.to = to if isinstance(to, list) else [to] + + def set_subject(self, subject): + self.subject = subject + + def set_from_email(self, from_email): + self.from_email = from_email + + def set_text_body(self, body): + self.body = strip_tags(body) + + def set_html_body(self, html_body): + self.body = html_body + + def load_template(self, path=None, context={}): + if path is None: + raise Exception("Email temaplate path is not provided.") + + self.content_subtype = "html" + html_body = render_to_string(path, context=context) + self.body = html_body + + def attach(self, file_path): + self.email.attach_file(file_path) + + def send(self): + try: + self.email = EmailMessage( + subject=self.subject, + body=self.body, + to=self.to, + from_email=self.from_email, + ) + + self.email.content_subtype = self.content_subtype + + self.email.send() + except SMTPException as e: + logger.error(str(e)) + + +class SMSError(Exception): + def __init__(self, message, payload=None): + self.message = message + self.payload = payload + + def __str__(self): + return str(self.message) + + +class SMSService: + def send(self, to: list, text: str): + """ + Sends text sms to the given user(s). + + Parameters: + to (list): list of phone numbers + + text (str): a text message. + + Return: + True or False + """ + + # if settings.TESTING_ENV: + # logger.info(f"TESTING ENV SMS LOG : {text}") + # return True + + account_sid = settings.TWILIO_ACCOUNT_SID + auth_token = settings.TWILIO_AUTH_TOKEN + my_twilio_number = settings.MY_TWILIO_NUMBER + + client = Client(account_sid, auth_token) + + try: + for number in to: + logger.info("SENDING SMS TO " + str(number)) + message = client.messages.create( + from_=my_twilio_number, body=text, to=number + ) + + except Exception as e: + logger.error(str(e)) + + raise SMSError(message=str(e)) + + def create_otp(self, principal: IAmPrincipal, opt_purpose: str): + otp = IAmPrincipalOtp.objects.create( + principal=principal, otp_purpose=opt_purpose + ) + otp.save() + return otp.otp_code + + def send_otp(self, principal: IAmPrincipal, otp_purpose: str): + """ + Sends otp to the given user. + + Parameters: + user (User): User object + otp_purpose (str) : a text that describe otp purpose + + Return: + True or False + """ + + if not isinstance(principal, IAmPrincipal): + raise Exception( + f"parameter 'principal' required type of User object, Given {type(principal)} type object" + ) + + otp_code = self.create_otp(principal=principal, otp_purpose=otp_purpose) + # below working will change as it is temporary purpose + body = f"Your Nifty11 OTP is {otp_code}." + + print(body) + + phone_numbers = [] + + try: + parsed_number = phonenumbers.parse(str(principal.phone_no), None) + if phonenumbers.is_valid_number(parsed_number): + formatted_number = phonenumbers.format_number( + parsed_number, phonenumbers.PhoneNumberFormat.E164 + ) + phone_numbers.append(formatted_number) + else: + raise ValueError("Invalid phone number") + except Exception as e: + logger.warning(f"{e}") + raise ValueError("Invalid phone number") + + if not phone_numbers: + raise ValueError("Invalid phone number") + + print(f"phone number {type(phone_numbers)} {phone_numbers}") + + # self.send(phone_numbers, body) + return otp_code + + +# Handling Transactions and Wallet Service +class WalletManager: + def __init__(self, principal, principal_type): + self.principal = principal + self.principal_type = get_object_or_404(IAmPrincipalType, name=principal_type) + self.wallet, created = Wallet.objects.get_or_create(principal=principal) + + def _update_balance(self, amount, field_name): + wallet_field = getattr(self.wallet, field_name) + wallet_field += amount + setattr(self.wallet, field_name, wallet_field) + + def _create_transaction(self, transaction_type, transaction_status, amount): + return Transaction.objects.create( + principal=self.principal, + principal_type=self.principal_type, + transaction_type=transaction_type, + transaction_status=transaction_status, + amount=amount, + ) + + def _initiate_transaction_deposit(self, amount): + return self._create_transaction("dp", "in", amount) + + @transaction.atomic + def deposit(self, amount, field_name): + # amount_decimal = Decimal(amount) / Decimal(100) #if the payment gateway deals in fils + amount_decimal = Decimal(amount) + self._update_balance(amount_decimal, field_name) + transaction = self._create_transaction("dp", "sc", amount_decimal) + self.wallet.save() + return transaction + + @transaction.atomic + def withdraw(self, amount, field_name): + # amount_decimal = Decimal(amount) / Decimal(100) #if the payment gateway deals in fils + amount_decimal = Decimal(amount) + if getattr(self.wallet, field_name) >= amount_decimal: + print(True) + self._update_balance( + -amount_decimal, field_name + ) # Subtract from the specified field + transaction = self._create_transaction("wd", "sc", amount_decimal) + self.wallet.save() + return transaction + else: + raise Exception(f"Insufficient funds in {field_name}") + + +class InteractionCalculator: + def __init__(self, event): + self.event = event + + def get_going_percentage(self): + if self.event.venue_capacity > 0: + going_count = EventPrincipalInteraction.objects.filter( + event=self.event, status="going" + ).count() + return (going_count / self.event.venue_capacity) * 100 + else: + # Raise an exception if venue_capacity is 0 + raise CapacityError() + + def get_interested_percentage(self): + if self.event.venue_capacity > 0: + interested_count = EventPrincipalInteraction.objects.filter( + event=self.event, status="interested" + ).count() + return (interested_count / self.event.venue_capacity) * 100 + else: + # Raise an exception if venue_capacity is 0 + raise CapacityError() + + def calculate(self): + going_percentage = self.get_going_percentage() + interested_percentage = self.get_interested_percentage() + + interaction = {"going": "None", "interested": "None"} + + if going_percentage >= 70: + interaction["going"] = "Exclusive" + elif going_percentage >= 50: + interaction["going"] = "Fire" + + if interested_percentage >= 90: + interaction["interested"] = "Red Flames" + elif interested_percentage >= 70: + interaction["interested"] = "Orange Flames" + elif interested_percentage >= 50: + interaction["interested"] = "Blue Flames" + + return interaction + + +class DashboardGamesHelper: + def __init__(self, game_type, game_index, live_time_end): + self.games = Game.objects.filter(deleted=False, draft=False) + self.game_type = game_type + self.game_index = game_index + self.live_time_end = live_time_end + self.current_datetime = timezone.localtime(timezone.now()) + self.current_date = self.current_datetime.date() + self.current_time = self.current_datetime.time() + # self.current_datetime = datetime(2023, 12, 19, 1, 29) + # self.current_date = self.current_datetime.date() + # self.current_time = self.current_datetime.time() + + def get_live_date(self, game_type): + if game_type == "Nifty": + return self._get_live_date_nifty() + elif game_type in ["Dow", "Nasdaq"]: + return self._get_live_date_dow_nasdaq() + else: + # Handle unknown game_type or raise an exception + return None + + def get_live_date_new(self, game_index): + if game_index == "Nifty": + print("Nifty Entered") + return self._is_cutoff_passed_nifty() + elif game_index in ["Dow", "Nasdaq"]: + print("Dow, Nasdaq Entered") + return self._is_cutoff_passed_nasdaq_dow() + else: + # Handle unknown game_type or raise an exception + return None + + def _get_updated_date(self, max_days, updated_date): + days_counter = 0 + + while days_counter < max_days: + updated_date += timedelta(days=1) + print("updated_date: ", updated_date) + queryset = self.games.filter( + deleted=False, + draft=False, + open_pool=True, + game_type=self.game_type, + game_index=StockIndexType.objects.filter(title=self.game_index).first(), + live_date=updated_date, + ) + + print("services.py queryset: ", queryset) + + if queryset.exists(): + print("queryset: ", queryset) + return queryset + days_counter += 1 + + return None + + def _is_cutoff_passed_nifty(self): + if self.current_time >= self.live_time_end: + return self._get_updated_date(10, updated_date=self.current_date) + + else: + selected_game = self.games.filter( + deleted=False, + draft=False, + open_pool=True, + game_type=self.game_type, + game_index=StockIndexType.objects.filter(title=self.game_index).first(), + live_date=self.current_date, + ) + print("selected_game: ", selected_game) + if selected_game: + return selected_game + return self._get_updated_date(10, updated_date=self.current_date) + + def _is_cutoff_passed_nasdaq_dow(self): + if self.current_time >= self.live_time_end: + return self._get_updated_date( + 10, updated_date=self.current_date - timedelta(days=1) + ) + + else: + selected_game = self.games.filter( + deleted=False, + draft=False, + open_pool=True, + game_type=self.game_type, + game_index=StockIndexType.objects.filter(title=self.game_index).first(), + live_date=self.current_date, + ) + print("selected_game: ", selected_game) + if selected_game: + return selected_game + return self._get_updated_date( + 10, updated_date=self.current_date - timedelta(days=1) + ) + + def _get_live_date_nifty(self): + next_day = self.current_date + timedelta(days=1) + if ( + self.current_date.weekday() == 4 and self.current_time >= self.live_time_end + ) or self.current_date.weekday() in {5, 6}: + # Find the next Monday + days_until_monday = 7 - self.current_date.weekday() + live_date = self.current_date + timedelta(days=days_until_monday) + else: + live_date = ( + self.current_date + if self.current_time < self.live_time_end + else next_day + ) + + return live_date + + def _get_live_date_dow_nasdaq(self): + if ( + self.current_date.weekday() == 5 and self.current_time >= self.live_time_end + ) or self.current_date.weekday() in {6, 7}: + # Find the next Monday + days_until_monday = 7 - self.current_date.weekday() + live_date = self.current_date + timedelta(days=days_until_monday) + else: + live_date = ( + self.current_date - timedelta(days=1) + if self.current_time < self.live_time_end + else self.current_date + ) + + return live_date + + +class GameStatusManager: + def __init__(self): + self.current_date = timezone.localtime( + timezone.now() + ).date() # More descriptive variable name + + def filter_eligible_games(self, game_index): + eligible_games = ( + Game.objects.filter( + status__in=("Upcoming", "Participate"), + # status=("Live"), + live_date=self.current_date, + deleted=False, + draft=False, + game_index=self.get_stock_index(game_index), + # min_no_of_team__isnull=False # Ensure min_no_of_team is set + ) + # .values("min_no_of_team", "game_team") # Include min_no_of_team + .annotate(teams_joined=Count("game_team")).filter( + teams_joined__gte=F("min_no_of_team") + ) + ) + + print("eligible_games: ", eligible_games) + eligible_games.update(status=Game.LIVE) + + def filter_live_games_by_index(self, game_index): + live_games = Game.objects.filter( + status=Game.LIVE, # Filter only live games + game_index=self.get_stock_index(game_index), + deleted=False, + draft=False, + ) + + return live_games + + def get_stock_index(self, game_index): + try: + return StockIndexType.objects.get(title=game_index) + except StockIndexType.DoesNotExist: + raise ValueError(f"StockIndexType with title '{game_index}' not found.") + + def manage_game_status_after_live(self, game): + game.status = "Completed" + game.save() + + +class ScoreCalculationManager: + def __init__(self): + self.current_datetime = timezone.localtime(timezone.now()) + + def get_completed_games(self, game_index): + try: + index = StockIndexType.objects.get(title=game_index) + except StockIndexType.DoesNotExist: + raise ValueError(f"StockIndexType with title '{game_index}' not found.") + + return Game.objects.filter( + status="Completed", + game_index=index, + ) + + def get_live_games(self, game_index): + try: + index = StockIndexType.objects.get(title=game_index) + except StockIndexType.DoesNotExist: + raise ValueError(f"StockIndexType with title '{game_index}' not found.") + + return Game.objects.filter( + status="Live", + game_index=index, + ) + + def calculate_team_score(self, game): + """Calculates team scores from team stocks' stock_scores.""" + # Subquery to get the total stock score for each team + total_stock_score_subquery = ( + TeamStock.objects.filter(team=OuterRef("pk")) + .values("team") + .annotate(total_stock_score=Sum("stock_score")) + .values("total_stock_score")[:1] + ) + + # Update team scores using the subquery + Team.objects.filter(game=game).annotate( + total_stock_score=Subquery(total_stock_score_subquery) + ).update(team_score=F("total_stock_score")) + + # call this function to update the last closing before 10 min of game live time start + def update_closing(self, game, close_price_field_name): + """Updates the closing prices for team stocks in a given game.""" + try: + closing_field = TeamStock._meta.get_field(close_price_field_name) + except Exception as e: + raise ValueError( + f"Field '{close_price_field_name}' not found in StockPrice Model." + ) + with transaction.atomic(): + if close_price_field_name == "current_closing": + # Generate a random adjustment between -100 and 100 + random_adjustment = random.randint(-100, 100) + + # Use a separate queryset for random adjustment + random_adjusted_prices = ( + StockPrice.objects.filter(stock=OuterRef("stock")) + .order_by("-timestamp") + .values("close_price")[:1] + .annotate(adjusted_price=F("close_price") + random_adjustment) + ) + + # Apply random adjustment only for 'current_closing' + team_stocks = TeamStock.objects.filter(team__game=game) + team_stocks.update( + **{ + close_price_field_name: Subquery( + random_adjusted_prices.values("adjusted_price") + ) + } + ) + else: + # Normal update for other fields + latest_prices = ( + StockPrice.objects.filter(stock=OuterRef("stock")) + .order_by("-timestamp") + .values("close_price")[:1] + ) + + team_stocks = TeamStock.objects.filter(team__game=game) + team_stocks.update(**{close_price_field_name: Subquery(latest_prices)}) + + def calculate_team_stock_scores(self, game): + """Call this function to update the stock score and current closing percentage + after 1 min of game live time end.""" + team_stocks = TeamStock.objects.filter(team__game=game) + + for team_stock in team_stocks: + ( + team_stock.stock_score, + team_stock.current_closing_percentage, + ) = self.calculate_stock_score( + team_stock.position, + team_stock.last_closing, + team_stock.current_closing, + team_stock.operation, + team_stock.quantity, + ) + team_stock.save() + + def calculate_stock_score( + self, position, last_closing, current_closing, operation, quantity + ): + """Calculates the stock score based on position and operation.""" + last_closing = Decimal(str(last_closing)) # Convert to Decimal + current_closing = Decimal(str(current_closing)) # Convert to Decimal + quantity = Decimal(str(quantity)) + + if operation == "Buy": + stock_score = ( + ((current_closing - last_closing) / last_closing) * 100 * quantity + ) + elif operation == "Sell": + stock_score = ( + ((last_closing - current_closing) / last_closing) * 100 * quantity + ) + else: + raise ValueError("Invalid operation") + + if position == "Captain": + stock_score *= Decimal("2") + elif position == "Vice Captain": + stock_score *= Decimal("1.5") + + current_closing_percentage = current_closing * 100 / last_closing + return stock_score, current_closing_percentage + + def prepare_leaderboard(self, game): + """Prepares a leaderboard based on specified winner type. + + Args: + game (Game): The game for which to create the leaderboard. + winner_type (str): The type of winner determination (One Winner, Three Winner, fifty Percent). + + Returns: + list: A list of winning teams, or an empty list if no winners. + """ + # game = Game.objects.get(pk=game) + # teams = Team.objects.filter(game=game).order_by("-team_score", "id") + teams = Team.objects.filter(game=game).order_by("team_rank") + + if game.winner_type == Game.ONE_DIST: + return teams[:1] + elif game.winner_type == Game.THREE_DIST: + return teams[:3] + elif game.winner_type == Game.FIFTY_DIST: + winners_count = round(teams.count() / 2) + print("winners_count: ", winners_count) + return teams[:winners_count] + elif game.winner_type == Game.BASIC_DIST: + winners_count = round(teams.count() / 2) + return teams[:winners_count] + elif game.winner_type == Game.PRO_DIST: + winners_count = round(teams.count() / 2) + return teams[:winners_count] + else: + raise ValueError(f"Invalid winner_type: {game.winner_type}") + + def calculate_team_rank(self, game): + """Calculate and update the rank for each team based on team_score.""" + print("Calculate and update the rank for each team based on team_score.") + + teams = Team.objects.filter(game=game).order_by("-team_score", "id") + + # Iterate through the teams and update the team_rank field + for i, team in enumerate(teams, start=1): + team.team_rank = i + team.save() + + def calculate_prize_amount(self, game): + """Calculates and Returns the prize amount for a game. + + Args: + game (Game): The game for which to distribute prizes. + + Returns: + float: The prize amount awarded to the winner. + + Raises: + ValueError: If there are no teams in the game or invalid prize parameters. + """ + + team_count = Team.objects.filter(game=game).count() + if team_count == 0: + raise ValueError("Cannot distribute prizes with no teams in the game.") + + try: + gross_amount = team_count * game.entry_fee + prize_amount = (team_count * game.entry_fee) - ( + team_count * game.entry_fee * game.commission_percentage / 100 + ) + game.gross_amount = gross_amount + game.distribution_amount = prize_amount + game.save() + + return prize_amount + except TypeError: + raise ValueError("Invalid prize parameters.") + + +class CreatePrivateGamesHelper: + def __init__(self, game_index): + self.current_datetime = timezone.localtime(timezone.now()) + self.game_index = game_index + self.live_time_start = ( + StockIndexType.objects.filter(title=game_index).first().live_time_start + ) + self.nifty_holiday_2024 = [ + "26-01-2024", + "08-03-2024", + "25-03-2024", + "29-03-2024", + "11-04-2024", + "17-04-2024", + "01-05-2024", + "17-06-2024", + "17-07-2024", + "15-08-2024", + "02-10-2024", + "01-11-2024", + "15-11-2024", + "25-12-2024", + ] + self.nasdaq_dow_holiday_2024 = [ + "01-01-2024", + "15-01-2024", + "19-02-2024", + "29-03-2024", + "27-05-2024", + "19-06-2024", + "03-07-2024", + "04-07-2024", + "02-09-2024", + "28-11-2024", + "29-11-2024", + "24-12-2024", + "25-12-2024", + ] + + def is_cutoff_passed(self): + time_start = datetime.combine( + self.current_datetime.date(), self.live_time_start + ) + cutoff_datetime = timezone.make_aware(time_start) - timedelta( + minutes=15 + ) # Make cutoff_datetime timezone-aware + print("cutoff_datetime: ", cutoff_datetime) + return self.current_datetime >= cutoff_datetime + + def find_valid_date(self): + """ + Finds a date that is not a Saturday, Sunday, or in the relevant holiday list, starting from today or tomorrow. + """ + # Start with today or tomorrow based on cutoff time + target_date = ( + datetime.today() + if not self.is_cutoff_passed() + else datetime.today() + timedelta(days=1) + ) + + counter, max_counter = 0, 7 # Limit the search to a maximum of 7 days + while counter < max_counter: + weekday = target_date.weekday() + date_str = target_date.strftime("%Y-%m-%d") + + # Select the correct holiday list based on game_index + holiday_list = ( + self.nifty_holiday_2024 + if self.game_index == "Nifty" + else self.nasdaq_dow_holiday_2024 + ) + + # Increment the counter for each iteration + counter += 1 + + # Check if the date is a Saturday, Sunday, or a holiday + if weekday in (5, 6) or date_str in holiday_list: + target_date += timedelta(days=1) + else: + # A valid date is found + break + + return target_date + + +class PrizeDistributionChecker: + def __init__(self, game): + self.game = game + self.team_count = self.get_team_count() + self.gross_amount = 0 + self.prize_amount = 0 + + def get_team_count(self): + """Retrieve the count of teams participating in the game.""" + return Team.objects.filter(game=self.game).count() + + def calculate_prize_amount(self): + """Calculate and set the prize amount for the game. + + Raises: + ValueError: If there are no teams in the game or invalid prize parameters. + """ + if self.team_count == 0: + raise ValueError("Cannot distribute prizes with no teams in the game.") + + try: + self.gross_amount = self.team_count * self.game.entry_fee + self.prize_amount = self.gross_amount - ( + self.gross_amount * self.game.commission_percentage / 100 + ) + self.update_game() + except TypeError: + raise ValueError("Invalid prize parameters.") + + return self.prize_amount + + def update_game(self): + """Update the game instance with calculated amounts and persist changes.""" + self.game.gross_amount = self.gross_amount + self.game.distribution_amount = self.prize_amount + self.game.save() + + +class BasicWinnerCalculator: + """ + Calculates the prize money for a given position in a competition based on the number of teams, ticket amount, gross amount, and price amount. + + Attributes: + fixed_winners_share (list): A list of fixed winners' share percentages for positions 5 to 10. + fixed_teams_percentage (list): A list of fixed teams percentages for positions 5 to 10. + first_four_teams (list): A list of fixed number of teams for positions 1 to 4. + first_four_share (list): A list of fixed winners' share percentages for positions 1 to 4. + + Methods: + calculate_prize_money(self, position, number_of_teams, ticket_amount, gross_amount, price_amount): + Calculates the prize money, winners' share, and number of teams for the given position. + """ + + def __init__(self): + self.fixed_winners_share = [2.5, 2, 1.5, 1.25, 1, 0.5] + self.fixed_teams_percentage = [1, 1, 5, 10, 20, 35] + self.first_four_teams = [1, 1, 1, 3] + self.first_four_percentage = [1, 0.75, 0.5, 0.75] + + def calculate_prize_money(self, position, number_of_teams, ticket_amount): + """ + Calculates the prize money, winners' share, and number of teams for the given position. + """ + + if not 1 <= position <= 10: + return None, None, None + + # For positions 5-10, use the fixed winners' share and teams percentage + if position >= 5: + index = position - 5 + winners_share = self.fixed_winners_share[index] * ticket_amount + percentage_of_teams = self.fixed_teams_percentage[index] / 100 + rounded_teams = round(number_of_teams * percentage_of_teams) + # For positions 1-4, calculate based on first four configurations + else: + index = position - 1 + winners_share = number_of_teams * ( + self.first_four_percentage[index] / self.first_four_teams[index] + ) + rounded_teams = self.first_four_teams[index] + + prize_money = winners_share * rounded_teams + + return prize_money, winners_share, rounded_teams + + def calculate_all_prizes(self, number_of_teams, ticket_amount): + all_prizes = [] + cumulative_rounding_error = 0.0 + + for position in range(1, 11): + prize, rounding_error = self.calculate_prize_for_position( + position, number_of_teams, ticket_amount + ) + all_prizes.append(prize) + cumulative_rounding_error += rounding_error + + self.adjust_prizes(all_prizes, cumulative_rounding_error) + return all_prizes + + def calculate_prize_for_position(self, position, number_of_teams, ticket_amount): + # Your prize calculation logic here, returning both the rounded prize and the rounding error + # return prize, rounding_error + pass + + def adjust_prizes(self, all_prizes, rounding_error): + # Logic to adjust the prizes based on the cumulative rounding error + pass diff --git a/goodtimes/settings.py b/goodtimes/settings.py new file mode 100644 index 0000000..0b9bb35 --- /dev/null +++ b/goodtimes/settings.py @@ -0,0 +1,123 @@ +""" +Django settings for goodtimes project. + +Generated by 'django-admin startproject' using Django 5.0.2. + +For more information on this file, see +https://docs.djangoproject.com/en/5.0/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/5.0/ref/settings/ +""" + +from pathlib import Path + +# Build paths inside the project like this: BASE_DIR / 'subdir'. +BASE_DIR = Path(__file__).resolve().parent.parent + + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/5.0/howto/deployment/checklist/ + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = "django-insecure-6c@e6u4h677duuoa2v3*m1ke&f+txh7s-q27e#=j_=a+9l6txi" + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = True + +ALLOWED_HOSTS = [] + + +# Application definition + +INSTALLED_APPS = [ + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", +] + +MIDDLEWARE = [ + "django.middleware.security.SecurityMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", +] + +ROOT_URLCONF = "goodtimes.urls" + +TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", + ], + }, + }, +] + +WSGI_APPLICATION = "goodtimes.wsgi.application" + + +# Database +# https://docs.djangoproject.com/en/5.0/ref/settings/#databases + +DATABASES = { + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": BASE_DIR / "db.sqlite3", + } +} + + +# Password validation +# https://docs.djangoproject.com/en/5.0/ref/settings/#auth-password-validators + +AUTH_PASSWORD_VALIDATORS = [ + { + "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", + }, +] + + +# Internationalization +# https://docs.djangoproject.com/en/5.0/topics/i18n/ + +LANGUAGE_CODE = "en-us" + +TIME_ZONE = "UTC" + +USE_I18N = True + +USE_TZ = True + + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/5.0/howto/static-files/ + +STATIC_URL = "static/" + +# Default primary key field type +# https://docs.djangoproject.com/en/5.0/ref/settings/#default-auto-field + +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" diff --git a/goodtimes/settings/__init__.py b/goodtimes/settings/__init__.py new file mode 100644 index 0000000..add6bae --- /dev/null +++ b/goodtimes/settings/__init__.py @@ -0,0 +1,24 @@ +import environ +from pathlib import Path + + +BASE_DIR = Path(__file__).resolve().parent.parent.parent + +env = environ.Env() + +READ_DOT_ENV_FILE = env.bool("DJANGO_READ_DOT_ENV_FILE", default=True) +if READ_DOT_ENV_FILE: + # OS environment variables take precedence over variables from .env + env.read_env(str(BASE_DIR / ".env")) + +# Access the "ENV_NAME" environment variable using env +env_name = env("ENV_NAME") + +if env_name == 'Production': + from .production import * # noqa +elif env_name == 'Staging': + from .staging import * # noqa +elif env_name == 'Development': + from .development import * # noqa +else: + raise ValueError("Invalid or missing ENV_NAME environment variable") diff --git a/goodtimes/settings/base.py b/goodtimes/settings/base.py new file mode 100644 index 0000000..d7a3363 --- /dev/null +++ b/goodtimes/settings/base.py @@ -0,0 +1,350 @@ +""" +Django settings for nifty11_project project. + +Generated by 'django-admin startproject' using Django 4.2.4. + +For more information on this file, see +https://docs.djangoproject.com/en/4.2/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/4.2/ref/settings/ +""" + +from pathlib import Path +import os +from django.contrib.messages import constants as messages + +# from decouple import config +import colorlog +import datetime +import environ + + +# Build paths inside the project like this: BASE_DIR / 'subdir'. +BASE_DIR = Path(__file__).resolve().parent.parent.parent + +env = environ.Env() + +READ_DOT_ENV_FILE = env.bool("DJANGO_READ_DOT_ENV_FILE", default=True) +if READ_DOT_ENV_FILE: + # OS environment variables take precedence over variables from .env + env.read_env(str(BASE_DIR / ".env")) + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/4.2/howto/deployment/checklist/ + +# SECURITY WARNING: keep the secret key used in production secret! +# SECRET_KEY = 'django-insecure-=7*xxmlhr=01o5^qfbtf_(b6b4udf5^!6g(7jt9*pxf4ng(k58' +SECRET_KEY = env.str("SECRET_KEY") + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = env.bool("DJANGO_DEBUG", True) +# DEBUG = True + + +# Application definition + +DJANGO_APPS = [ + "daphne", + "channels", + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", +] + +LOCAL_APPS = [ + "dashboard", + "accounts", + "manage_wallets", + "manage_subscriptions", + "manage_events", + "manage_referrals", + "manage_cms", + "manage_communications", # for contact us, and feedback + "chat", +] + +THIRD_PARTY_APPS = [ + "rest_framework", + "widget_tweaks", + "rest_framework_simplejwt", + "phonenumber_field", + "taggit", + "django_quill", + "corsheaders", + "allauth", + "allauth.account", + "allauth.socialaccount", + "allauth.socialaccount.providers.apple", + "allauth.socialaccount.providers.google", + # "django_crontab", + # "django_celery_results", + # "django_celery_beat", +] + +# https://docs.djangoproject.com/en/dev/ref/settings/#installed-apps +INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS + + +MIDDLEWARE = [ + "corsheaders.middleware.CorsMiddleware", + "django.middleware.security.SecurityMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", + "allauth.account.middleware.AccountMiddleware", +] + +ROOT_URLCONF = "goodtimes.urls" + +TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [BASE_DIR.joinpath("templates")], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", + "accounts.context_processors.resource_action_constants", # resource action context processor + ], + }, + }, +] + +# WSGI_APPLICATION = "goodtimes.wsgi.application" +ASGI_APPLICATION = "goodtimes.asgi.application" + + +# Database +# https://docs.djangoproject.com/en/4.2/ref/settings/#databases + +DATABASES = { + "default": { + "ENGINE": "django.db.backends.mysql", + "NAME": env.str("DB_DATABASE"), + "HOST": env.str("DB_HOST"), + "USER": env.str("DB_USERNAME"), + "PASSWORD": env.str("DB_PASSWORD"), + "PORT": env.str("DB_PORT"), + } +} +DATABASES["default"]["ATOMIC_REQUESTS"] = True + +# Password validation +# https://docs.djangoproject.com/en/4.2/ref/settings/#auth-password-validators + +AUTH_PASSWORD_VALIDATORS = [ + { + "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", + }, +] + +APPEND_SLASH = True +LOGIN_REDIRECT_URL = "/dashboard/main-dashboard/" +LOGIN_URL = "/account/login/" +LOGOUT_REDIRECT_URL = "/account/login/" + +# https://docs.djangoproject.com/en/4.2/topics/auth/customizing/#substituting-a-custom-user-model +AUTH_USER_MODEL = "accounts.IAmPrincipal" + +# https://docs.djangoproject.com/en/4.2/topics/auth/customizing/ +AUTHENTICATION_BACKENDS = [ + # 'accounts.backend.EmailBackend', + "django.contrib.auth.backends.ModelBackend", + "allauth.account.auth_backends.AuthenticationBackend", +] + +# rest framework permission and authentication settings +# https://www.django-rest-framework.org/api-guide/permissions/#setting-the-permission-policy +REST_FRAMEWORK = { + # Use Django's standard `django.contrib.auth` permissions, + # or allow read-only access for unauthenticated users. + "DEFAULT_PERMISSION_CLASSES": [ + "rest_framework.permissions.IsAuthenticated", + ], + "DEFAULT_AUTHENTICATION_CLASSES": ( + "rest_framework_simplejwt.authentication.JWTAuthentication", + # "accounts.api.authenticate.CustomAuthentication", + ), +} + +# Internationalization +# https://docs.djangoproject.com/en/4.2/topics/i18n/ + +LANGUAGE_CODE = "en-us" + +TIME_ZONE = "Asia/Kolkata" + +USE_I18N = True + +USE_L10N = True + +USE_TZ = True + +SHORT_DATETIME_FORMAT = "d-m-Y H:i:s" + +SHORT_DATE_FORMAT = "d-m-Y" + +TIME_FORMAT = "H:i p" + +# otp expire time limit +OTP_EXPIRE_TIME = 10 # mins + + +# Default primary key field type +# https://docs.djangoproject.com/en/4.2/ref/settings/#default-auto-field + +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" + + +MESSAGE_TAGS = { + messages.DEBUG: "alert-info", + messages.INFO: "alert-info", + messages.SUCCESS: "alert-success", + messages.WARNING: "alert-warning", + messages.ERROR: "alert-danger", +} + + +# smtp email settings +# https://docs.djangoproject.com/en/4.2/topics/email/#smtp-backend + +EMAIL_BACKEND = env.str( + "EMAIL_BACKEND", default="django.core.mail.backends.smtp.EmailBackend" +) +EMAIL_HOST = env.str("EMAIL_HOST") +EMAIL_HOST_USER = env.str("EMAIL_HOST_USER") +EMAIL_HOST_PASSWORD = env.str("EMAIL_HOST_PASSWORD") +EMAIL_PORT = env.str("EMAIL_PORT") +EMAIL_USE_TLS = True + + +# LOGGING +# ------------------------------------------------------------------------------ +# https://docs.djangoproject.com/en/4.2/topics/logging/#logging +# https://docs.djangoproject.com/en/dev/ref/settings/#logging +# See https://docs.djangoproject.com/en/dev/topics/logging for +# more details on how to customize your logging configuration. +LOGGING = { + "version": 1, + "disable_existing_loggers": False, + "formatters": { + "verbose": { + "()": colorlog.ColoredFormatter, + "format": "%(cyan)s%(asctime)s%(reset)s | %(red)s[%(levelname)8s]%(reset)s | [ %(yellow)s%(name)s.%(module)s:%(white)s%(lineno)d%(reset)s - %(green)s%(funcName)10s()%(reset)s ] --> %(message)s", + "datefmt": "%Y-%m-%d %H:%M:%S", + "log_colors": { + "DEBUG": "white", + "INFO": "green", + "WARNING": "yellow", + "ERROR": "red", + "CRITICAL": "bold_red", + }, + "secondary_log_colors": {}, + "style": "%", + } + }, + "handlers": { + "console": { + "level": "DEBUG", + "class": "logging.StreamHandler", + "formatter": "verbose", + } + }, + "root": {"level": "INFO", "handlers": ["console"]}, +} + +# jwt configuration +# https://django-rest-framework-simplejwt.readthedocs.io/en/latest/settings.html#settings +SIMPLE_JWT = { + "ACCESS_TOKEN_LIFETIME": datetime.timedelta(days=10), + "REFRESH_TOKEN_LIFETIME": datetime.timedelta(days=15), + "ROTATE_REFRESH_TOKENS": False, + "BLACKLIST_AFTER_ROTATION": False, + "UPDATE_LAST_LOGIN": False, + "ALGORITHM": "HS256", + "SIGNING_KEY": SECRET_KEY, + "VERIFYING_KEY": "", + "AUDIENCE": None, + "ISSUER": None, + "JSON_ENCODER": None, + "JWK_URL": None, + "LEEWAY": 0, + "AUTH_HEADER_TYPES": ("Bearer",), + "AUTH_HEADER_NAME": "HTTP_AUTHORIZATION", + "USER_ID_FIELD": "id", + "USER_ID_CLAIM": "user_id", + "USER_AUTHENTICATION_RULE": "rest_framework_simplejwt.authentication.default_user_authentication_rule", + "AUTH_TOKEN_CLASSES": ("rest_framework_simplejwt.tokens.AccessToken",), + "TOKEN_TYPE_CLAIM": "token_type", + "TOKEN_USER_CLASS": "rest_framework_simplejwt.models.TokenUser", + "JTI_CLAIM": "jti", +} + +STRIPE_SECRET_KEY = env.str("STRIPE_SECRET_KEY") + +CHANNEL_LAYERS = { + "default": { + "BACKEND": "channels_redis.core.RedisChannelLayer", + "CONFIG": { + # "hosts": [("192.168.29.219", 6379)], + "hosts": [("127.0.0.1", 6379)], + }, + }, +} + +# Celery Setting +# CELERY_TIMEZONE = "Asia/Kolkata" +# CELERY_BROKER_URL = "redis://localhost:6379" +# CELERY_RESULT_BACKEND = "redis://localhost:6379" +# CELERY_RESULT_BACKEND = "django-db" +# CELERY_RESULT_EXTENDED = True +# CELERY_BEAT_SCHEDULER = "django_celery_beat.schedulers:DatabaseScheduler" + +WEBSOCKET_TIMEOUT = 30 + +CRONJOBS = [ + # ("0 9 * * 1-5", "manage_games.cron.update_game_status_live"), +] + +GOOGLE_OAUTH2_CLIENT_ID = env.str("DJANGO_GOOGLE_OAUTH2_CLIENT_ID", default="") +GOOGLE_OAUTH2_CLIENT_SECRET = env.str("DJANGO_GOOGLE_OAUTH2_CLIENT_SECRET", default="") +GOOGLE_OAUTH2_PROJECT_ID = env.str("DJANGO_GOOGLE_OAUTH2_PROJECT_ID", default="") + +GOOGLE_MAPS_API_KEY = env.str("GOOGLE_MAPS_API_KEY") + +SOCIALACCOUNT_PROVIDERS = { + "google": { + "APP": { + "client_id": GOOGLE_OAUTH2_CLIENT_ID, # replace me + "secret": GOOGLE_OAUTH2_CLIENT_SECRET, # replace me + "key": "", # leave empty + }, + "SCOPE": [ + "profile", + "email", + ], + "AUTH_PARAMS": { + "access_type": "online", + }, + "VERIFIED_EMAIL": True, + }, +} diff --git a/goodtimes/settings/development.py b/goodtimes/settings/development.py new file mode 100644 index 0000000..be2b7f8 --- /dev/null +++ b/goodtimes/settings/development.py @@ -0,0 +1,55 @@ +from .base import * # noqa +import os + +DEBUG = True + +ALLOWED_HOSTS = [ + "*", + "192.168.1.24", + "192.168.1.13", + "192.168.1.11", + "192.168.1.25", + "192.168.1.26", + "192.168.29.211", + "ngrok.io", +] + +# CORS +CORS_ALLOWED_ORIGINS = [ + "http://127.0.0.1:3000", + "http://localhost:3000", +] + + +CORS_ORIGIN_ALLOW_ALL = True + + +CORS_ORIGIN_WHITELIST = ("http://localhost:3000",) + +if DEBUG: + MIDDLEWARE += [ + "debug_toolbar.middleware.DebugToolbarMiddleware", + ] + INSTALLED_APPS += [ + "debug_toolbar", + "django_extensions", + ] + INTERNAL_IPS = [ + "127.0.0.1", + ] + +BASE_DOMAIN = "http://192.168.29.219:8000" + + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/4.2/howto/static-files/ + + +MEDIA_URL = "/media/" +MEDIA_ROOT = os.path.join(BASE_DIR, "media") + +# STATIC_ROOT = os.path.join(BASE_DIR, "static") +STATIC_URL = "/static/" + + +STATICFILES_DIRS = [BASE_DIR.joinpath("static")] diff --git a/goodtimes/settings/production.py b/goodtimes/settings/production.py new file mode 100644 index 0000000..50b97a3 --- /dev/null +++ b/goodtimes/settings/production.py @@ -0,0 +1,77 @@ +from .base import * # noqa +from .base import env, BASE_DIR +import os +import colorlog +from logging.handlers import TimedRotatingFileHandler + +DEBUG = False + +ALLOWED_HOSTS = ["goodtimes.betadelivery.com"] + + +LOGGING_DIR = os.path.join( + BASE_DIR, "logs" +) # Define the directory where log files will be stored + +# Ensure the directory exists; create it if it doesn't +if not os.path.exists(LOGGING_DIR): + os.makedirs(LOGGING_DIR) + +LOGGING_LEVEL = env.str( + "LOG_LEVEL", "INFO" +) # Set your desired log level (e.g., DEBUG, INFO, WARNING, ERROR) + + +LOGGING = { + "version": 1, + "disable_existing_loggers": False, + "formatters": { + "verbose": { + "()": colorlog.ColoredFormatter, + "format": "%(cyan)s%(asctime)s%(reset)s | %(red)s[%(levelname)8s]%(reset)s | [ %(yellow)s%(name)s.%(module)s:%(white)s%(lineno)d%(reset)s - %(green)s%(funcName)10s()%(reset)s ] --> %(message)s", + "datefmt": "%Y-%m-%d %H:%M:%S", + "log_colors": { + "DEBUG": "white", + "INFO": "green", + "WARNING": "yellow", + "ERROR": "red", + "CRITICAL": "bold_red", + }, + "secondary_log_colors": {}, + "style": "%", + } + }, + "handlers": { + "logfile": { + "level": LOGGING_LEVEL, + "class": "logging.handlers.RotatingFileHandler", + "filename": os.path.join(LOGGING_DIR, "godtimes_pro_error.log"), + 'maxBytes': 5242880, # 5*1024*1024 bytes (5MB) + "backupCount": 10, # Number of log files to keep (15 days' worth of logs) + "formatter": "verbose", + }, + }, + "loggers": { + "django": { + "handlers": ["logfile"], + "level": LOGGING_LEVEL, + "propagate": False, + }, + }, +} + + +BASE_DOMAIN = "https://goodtimes.betadelivery.com" + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/4.2/howto/static-files/ + + +MEDIA_URL = "/media/" +MEDIA_ROOT = os.path.join(BASE_DIR, "media") + +# STATIC_ROOT = os.path.join(BASE_DIR, "static") +STATIC_URL = "/static/" + + +STATICFILES_DIRS = [BASE_DIR.joinpath("static")] diff --git a/goodtimes/settings/staging.py b/goodtimes/settings/staging.py new file mode 100644 index 0000000..17f6da8 --- /dev/null +++ b/goodtimes/settings/staging.py @@ -0,0 +1,75 @@ +from .base import * # noqa +from .base import env, BASE_DIR +import os +import colorlog +from logging.handlers import TimedRotatingFileHandler + +DEBUG = False +ALLOWED_HOSTS = ["goodtimes.betadelivery.com"] + + +LOGGING_DIR = os.path.join( + BASE_DIR, "logs" +) # Define the directory where log files will be stored + +# Ensure the directory exists; create it if it doesn't +if not os.path.exists(LOGGING_DIR): + os.makedirs(LOGGING_DIR) + +LOGGING_LEVEL = env.str( + "LOG_LEVEL", "INFO" +) # Set your desired log level (e.g., DEBUG, INFO, WARNING, ERROR) in the env file + + +LOGGING = { + "version": 1, + "disable_existing_loggers": False, + "formatters": { + "verbose": { + "()": colorlog.ColoredFormatter, + "format": "%(cyan)s%(asctime)s%(reset)s | %(red)s[%(levelname)8s]%(reset)s | [ %(yellow)s%(name)s.%(module)s:%(white)s%(lineno)d%(reset)s - %(green)s%(funcName)10s()%(reset)s ] --> %(message)s", + "datefmt": "%Y-%m-%d %H:%M:%S", + "log_colors": { + "DEBUG": "white", + "INFO": "green", + "WARNING": "yellow", + "ERROR": "red", + "CRITICAL": "bold_red", + }, + "secondary_log_colors": {}, + "style": "%", + } + }, + "handlers": { + "logfile": { + "level": LOGGING_LEVEL, + "class": "logging.handlers.RotatingFileHandler", + "filename": os.path.join(LOGGING_DIR, "goodtimes_staging_error.log"), + 'maxBytes': 5242880, # 5*1024*1024 bytes (5MB) + "backupCount": 10, # Number of log files to keep (15 days' worth of logs) + "formatter": "verbose", + }, + }, + "loggers": { + "django": { + "handlers": ["logfile"], + "level": LOGGING_LEVEL, + "propagate": False, + }, + }, +} + +BASE_DOMAIN = "https://goodtimes.betadelivery.com" + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/4.2/howto/static-files/ + + +MEDIA_URL = "/media/" +MEDIA_ROOT = os.path.join(BASE_DIR, "media") + +# STATIC_ROOT = os.path.join(BASE_DIR, "static") +STATIC_URL = "/static/" + + +STATICFILES_DIRS = [BASE_DIR.joinpath("static")] diff --git a/goodtimes/urls.py b/goodtimes/urls.py new file mode 100644 index 0000000..fedf0e7 --- /dev/null +++ b/goodtimes/urls.py @@ -0,0 +1,65 @@ +""" +URL configuration for goodtimes project. + +The `urlpatterns` list routes URLs to views. For more information please see: + https://docs.djangoproject.com/en/5.0/topics/http/urls/ +Examples: +Function views + 1. Add an import: from my_app import views + 2. Add a URL to urlpatterns: path('', views.home, name='home') +Class-based views + 1. Add an import: from other_app.views import Home + 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') +Including another URLconf + 1. Import the include() function: from django.urls import include, path + 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) +""" +from django.contrib import admin +from django.urls import include, path +from django.conf import settings +from django.conf.urls.static import static +from django.views.generic import RedirectView + + +urlpatterns = [ + path("admin/", admin.site.urls), + + path('accounts/', include('allauth.urls')), + + path('', RedirectView.as_view(url='/account/login/'), name='root-redirect'), + path('account/', include('accounts.urls')), + path('api/account/', include("accounts.api.urls")), + + path('dashboard/', include('dashboard.urls')), + # path('api/account', include("accounts.api.urls")), + + path('events/', include('manage_events.urls')), + path('api/events/', include("manage_events.api.urls")), + + # path('report/', include('manage_reports.urls')), + # path('api/report', include("manage_reports.api.urls")), + + path('cms/', include('manage_cms.urls')), + path('api/cms/', include("manage_cms.api.urls")), + + path('wallet/', include('manage_wallets.urls')), + # path('api/wallet/', include("manage_wallets.api.urls")), + + path('communications/', include('manage_communications.urls')), + # path('api/communications/', include("manage_communications.api.urls")), + + # path('chat/', include('chat.urls')), + # path('api/chat/', include("chat.api.urls")), + + path('subscriptions/', include('manage_subscriptions.urls')), + path('api/subscriptions/', include("manage_subscriptions.api.urls")), + + # path('', include('manage_notifications.urls')), + # path('api/', include("accounts.api.urls")), +] + +if settings.DEBUG: + import debug_toolbar + + urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) + urlpatterns += [path('__debug__/', include(debug_toolbar.urls))] diff --git a/goodtimes/utils.py b/goodtimes/utils.py new file mode 100644 index 0000000..cf592cb --- /dev/null +++ b/goodtimes/utils.py @@ -0,0 +1,66 @@ +from rest_framework.response import Response +from rest_framework import status +import string +import random +import logging +import os +import stat + +class GroupWriteRotatingFileHandler(logging.handlers.RotatingFileHandler): + + def doRollover(self): + """ + Overrides base class method to make the new log file group writable. + """ + # Rotate the file first. + logging.handlers.RotatingFileHandler.doRollover(self) + + # Add group write to the current permissions. + currMode = os.stat(self.baseFilename).st_mode + os.chmod(self.baseFilename, currMode | stat.S_IWGRP) + +class ApiResponse: + @staticmethod + def success(message, data=None, status=status.HTTP_200_OK): + response_data = {"success": True, "status": status, "message": message} + if data is not None: + response_data["data"] = data + return Response(response_data, status=status) + + @staticmethod + def error(message, errors=None, status=status.HTTP_400_BAD_REQUEST): + response_data = {"success": False, "status": status, "message": message} + if errors is not None: + response_data["errors"] = errors + return Response(response_data, status=status) + + # @staticmethod + # def validation_error(errors, status_code=status.HTTP_422_UNPROCESSABLE_ENTITY): + # return ApiResponse.error("Validation error", errors, status_code) + + +class RandomGenerator: + @staticmethod + def number(start, end): + # import random + return random.randint(start, end) + + @staticmethod + def password_reset_code(): + return RandomGenerator.number(10000000, 99999999) + + def random_otp(): + return RandomGenerator.number(1000, 9999) + + @staticmethod + def random_alphnum(length): + return "".join( + random.choice(string.ascii_uppercase + string.digits) for _ in range(length) + ) + + +class CapacityError(Exception): + """Exception raised for errors in the capacity of an event.""" + def __init__(self, message="Venue capacity must be greater than 0"): + self.message = message + super().__init__(self.message) diff --git a/goodtimes/wsgi.py b/goodtimes/wsgi.py new file mode 100644 index 0000000..6e7364b --- /dev/null +++ b/goodtimes/wsgi.py @@ -0,0 +1,16 @@ +""" +WSGI config for goodtimes project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/5.0/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "goodtimes.settings") + +application = get_wsgi_application() diff --git a/manage.py b/manage.py new file mode 100644 index 0000000..8b9469b --- /dev/null +++ b/manage.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" +import os +import sys + + +def main(): + """Run administrative tasks.""" + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "goodtimes.settings") + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == "__main__": + main() diff --git a/manage_cms/__init__.py b/manage_cms/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/manage_cms/admin.py b/manage_cms/admin.py new file mode 100644 index 0000000..a9dfa97 --- /dev/null +++ b/manage_cms/admin.py @@ -0,0 +1,34 @@ +from django.contrib import admin +from . models import Organization +# Register your models here. + + +class OrganizationAdmin(admin.ModelAdmin): + list_display = ('id', 'title', 'contact_us_email', 'website_url') + list_filter = ('title',) # You can add more fields for filtering + search_fields = ('title', 'contact_us_email', 'website_url') + list_per_page = 20 # Number of items displayed per page in the admin list view + + fieldsets = ( + ('Basic Information', { + 'fields': ('title', 'contact_us_email', 'website_url') + }), + ('Social Media', { + 'fields': ('instagram_handle', 'facebook_handle', 'linkedin_handle') + }), + ('Images', { + 'fields': ('logo_image', 'favicon_image') + }), + ('Text Fields', { + 'fields': ( + 'about_us', 'terms_condition', 'terms_condition_user', 'terms_condition_merchant', + 'privacy_policy', 'privacy_policy_user', 'privacy_policy_merchant', + 'subscription_agreement', 'license_agreement_user', 'license_agreement_merchant' + ) + }), + ) + + # readonly_fields = ('title',) # Add any other fields you want to make readonly + +admin.site.register(Organization, OrganizationAdmin) + diff --git a/manage_cms/api/serializers.py b/manage_cms/api/serializers.py new file mode 100644 index 0000000..b9385f6 --- /dev/null +++ b/manage_cms/api/serializers.py @@ -0,0 +1,119 @@ +import re + +from rest_framework import serializers +from accounts.models import IAmPrincipal +from goodtimes import constants, date_utils +from manage_cms import models +from taggit.models import Tag + + +class TagSerializer(serializers.ModelSerializer): + class Meta: + model = Tag + fields = ['id', 'name'] + +class NewsAndArticlesSerializer(serializers.ModelSerializer): + class Meta: + model = models.NewsAndArticles + fields = "__all__" + + +class NewsLetterSerializer(serializers.ModelSerializer): + class Meta: + model = models.Newsletter + fields = "__all__" + + +class FaqSerializer(serializers.ModelSerializer): + class Meta: + model = models.Faqs + fields = "__all__" + + +class OrganizationSerializer(serializers.ModelSerializer): + about_us = serializers.CharField(source='about_us.html', read_only=True) + terms_condition = serializers.CharField(source='terms_condition.html', read_only=True) + terms_condition_user = serializers.CharField(source='terms_condition_user.html', read_only=True) + terms_condition_merchant = serializers.CharField(source='terms_condition_merchant.html', read_only=True) + privacy_policy = serializers.CharField(source='privacy_policy.html', read_only=True) + privacy_policy_user = serializers.CharField(source='privacy_policy_user.html', read_only=True) + privacy_policy_merchant = serializers.CharField(source='privacy_policy_merchant.html', read_only=True) + subscription_agreement = serializers.CharField(source='subscription_agreement.html', read_only=True) + license_agreement_user = serializers.CharField(source='license_agreement_user.html', read_only=True) + license_agreement_merchant = serializers.CharField(source='license_agreement_merchant.html', read_only=True) + + class Meta: + model = models.Organization + fields = [ + "about_us", + "terms_condition", + "terms_condition_user", + "terms_condition_merchant", + "privacy_policy", + "privacy_policy_user", + "privacy_policy_merchant", + "subscription_agreement", + "license_agreement_user", + "license_agreement_merchant", + ] + +class EducationVideoSerializer(serializers.ModelSerializer): + tags = TagSerializer(many=True, read_only=True) + date = serializers.DateTimeField(source="published_at", format='%d %b, %Y') + + class Meta: + model = models.Education + fields = [ + "content_type", + "title", + "description", + "thumbnail", + "video_url", + "tags", + "date" + ] + + def get_image_url(self, obj, field_name, request): + image_field = getattr(obj, field_name) + if image_field: + return request.build_absolute_uri(image_field.url) + return "" + + def to_representation(self, instance): + data = super().to_representation(instance) + request = self.context.get("request") + data["thumbnail"] = self.get_image_url(instance, "thumbnail", request) + return data + +class EducationMaterialSerializer(serializers.ModelSerializer): + tags = TagSerializer(many=True, read_only=True) + date = serializers.DateTimeField(source="published_at", format='%d %b, %Y') + + class Meta: + model = models.Education + fields = [ + "content_type", + "title", + "file", + "tags", + "date" + ] + + def get_image_url(self, obj, field_name, request): + image_field = getattr(obj, field_name) + if image_field: + return request.build_absolute_uri(image_field.url) + return "" + + def to_representation(self, instance): + data = super().to_representation(instance) + request = self.context.get("request") + data["file"] = self.get_image_url(instance, "file", request) + return data + +# class EducationTagSerializer(serializers.ModelSerializer): +# tags = TagSerializer(many=True, read_only=True) + +# class Meta: +# model = models.Education +# fields = ["tags"] \ No newline at end of file diff --git a/manage_cms/api/urls.py b/manage_cms/api/urls.py new file mode 100644 index 0000000..7cf2897 --- /dev/null +++ b/manage_cms/api/urls.py @@ -0,0 +1,17 @@ +from django.urls import path +from . import views + +app_name = "manage_cms_api" + +urlpatterns = [ + + path('news-article-mobile/', views.NewsAndArticlesView.as_view(), name='news_article_mobile'), + path('newsletter-mobile/', views.NewsLettersView.as_view(), name='newsletter_mobile'), + path('faq-mobile/', views.FaqView.as_view(), name='faq_mobile'), + path('organization-mobile/', views.OrganizationView.as_view(), name='organization_mobile'), + + path('education/video/', views.EducationVideoView.as_view(), name='education_video'), + path('education/material/', views.EducationMaterialView.as_view(), name='education_material'), + path('education/tags/', views.EducationTagsView.as_view(), name='education_tags'), + +] diff --git a/manage_cms/api/views.py b/manage_cms/api/views.py new file mode 100644 index 0000000..7006733 --- /dev/null +++ b/manage_cms/api/views.py @@ -0,0 +1,158 @@ +from django.db import transaction +from django.utils import timezone +from rest_framework import status, generics +from rest_framework.views import APIView +from rest_framework_simplejwt.tokens import RefreshToken +from . import serializers +from accounts.models import IAmPrincipal +from goodtimes import constants +from goodtimes.services import SMSError, SMSService +from manage_cms import models +from taggit.models import Tag +from taggit.models import TaggedItem +from django.contrib.contenttypes.models import ContentType + +# from nifty11_project.services import SMSError, SMSService +from goodtimes.utils import ApiResponse +from goodtimes import date_utils +from rest_framework.permissions import AllowAny, IsAuthenticated +from rest_framework_simplejwt.authentication import JWTAuthentication + + +class NewsAndArticlesView(APIView): + authentication_classes = [JWTAuthentication] + permission_classes = [IsAuthenticated] + + def get(self, request): + queryset = models.NewsAndArticles.objects.filter(active=True) + serializer = serializers.NewsAndArticlesSerializer(queryset, many=True) + success_response = { + "status": status.HTTP_200_OK, + "message": constants.SUCCESS, + "data": serializer.data, + } + return ApiResponse.success(**success_response) + + +class NewsLettersView(APIView): + authentication_classes = [] + permission_classes = [] + + def get(self, request): + queryset = models.Newsletter.objects.filter(active=True, is_published=True) + serializer = serializers.NewsLetterSerializer(queryset, many=True) + + return ApiResponse.success( + {"data": serializer.data, "message": constants.SUCCESS} + ) + + +class FaqView(APIView): + authentication_classes = [] + permission_classes = [] + + def get(self, request): + queryset = models.Faqs.objects.filter(active=True) + serializer = serializers.FaqSerializer(queryset, many=True) + + success_response = { + "status": status.HTTP_200_OK, + "message": constants.SUCCESS, + "data": serializer.data, + } + return ApiResponse.success(**success_response) + + +class OrganizationView(APIView): + # queryset = models.Organization.objects.latest('id') + model = models.Organization + serializer_class = serializers.OrganizationSerializer + authentication_classes = [] + permission_classes = [] + + def get(self, request): + queryset = self.model.objects.filter(active=True).last() + serializer = self.serializer_class(queryset) + + success_response = { + "status": status.HTTP_200_OK, + "message": constants.SUCCESS, + "data": serializer.data, + } + return ApiResponse.success(**success_response) + + +class EducationVideoView(APIView): + model = models.Education + serializer_class = serializers.EducationVideoSerializer + authentication_classes = [] + permission_classes = [] + + def get(self, request): + queryset = self.model.objects.prefetch_related("tags").filter( + content_type=self.model.VIDEO, + active=True, + published_at__lte=date_utils.get_current_date().isoformat(), + withdrawn_at__gte=date_utils.get_current_date().isoformat(), + ) + serializer = self.serializer_class( + queryset, many=True, context={"request": request} + ) + success_response = { + "status": status.HTTP_200_OK, + "message": constants.SUCCESS, + "data": serializer.data, + } + return ApiResponse.success(**success_response) + + +class EducationMaterialView(APIView): + model = models.Education + serializer_class = serializers.EducationMaterialSerializer + authentication_classes = [] + permission_classes = [] + + def get(self, request): + queryset = self.model.objects.prefetch_related("tags").filter( + content_type=self.model.MATERIAL, + active=True, + published_at__lte=date_utils.get_current_date().isoformat(), + withdrawn_at__gte=date_utils.get_current_date().isoformat(), + ) + serializer = self.serializer_class( + queryset, many=True, context={"request": request} + ) + success_response = { + "status": status.HTTP_200_OK, + "message": constants.SUCCESS, + "data": serializer.data, + } + return ApiResponse.success(**success_response) + + +class EducationTagsView(APIView): + model = models.Education + serializer_class = serializers.TagSerializer + authentication_classes = [] + permission_classes = [] + + def get(self, request): + # Get ContentType for the Education model + education_content_type = ContentType.objects.get_for_model(self.model) + + # Fetch tags associated with Education instances + education_tags = ( + TaggedItem.objects.filter(content_type=education_content_type) + .values_list("tag", flat=True) + ) + + # Retrieve actual tag objects from Tag model using the tag IDs obtained + queryset = Tag.objects.filter(id__in=education_tags) + + serializer = self.serializer_class(queryset, many=True) + success_response = { + "status": status.HTTP_200_OK, + "message": constants.SUCCESS, + "data": serializer.data, + } + return ApiResponse.success(**success_response) diff --git a/manage_cms/apps.py b/manage_cms/apps.py new file mode 100644 index 0000000..4eb0d6b --- /dev/null +++ b/manage_cms/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class ManageCmsConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'manage_cms' diff --git a/manage_cms/forms.py b/manage_cms/forms.py new file mode 100644 index 0000000..1d79433 --- /dev/null +++ b/manage_cms/forms.py @@ -0,0 +1,216 @@ +from django import forms +from django.core.exceptions import ValidationError +from django.core import validators +from .models import ( + Organization, + NewsAndArticlesCategory, + NewsAndArticles, + Newsletter, + FaqCategory, + Faqs, + Education, +) + +from goodtimes import constants + +from accounts import resource_action + + +class OrganizationForm(forms.ModelForm): + class Meta: + model = Organization + fields = [ + "title", + "contact_us_email", + "instagram_handle", + "facebook_handle", + "linkedin_handle", + "logo_image", + "favicon_image", + "website_url", + ] + + labels = { + "title": "Organization Title", + "contact_us_email": "Contact Email", + "instagram_handle": "Instagram URL", + "facebook_handle": "Facebook URL", + "linkedin_handle": "LinkedIn URL", + "logo_image": "Organization Logo", + "favicon_image": "Favicon", + "website_url": "Website URL", + } + + +class NewsAndArticleCategoryForm(forms.ModelForm): + class Meta: + model = NewsAndArticlesCategory + fields = ["name"] + labels = {"name": "Category Name"} + + +class NewsAndArticlesForm(forms.ModelForm): + image_url = forms.ImageField() + + class Meta: + model = NewsAndArticles + fields = [ + "title", + "small_description", + "long_description", + "image_url", + "video_url", + "article_category", + "tags", + "active", + ] + labels = { + "title": "Article Title", + "small_description": "Small Description", + "long_description": "Long Description", + "image_url": "Image", + "video_url": "Video URL", + "article_category": "Article Category", + "tags": "Tags", + "active": "Active", + } + + def __init__(self, *args, **kwargs): + instance = kwargs.get("instance") + super().__init__(*args, **kwargs) + # Fetch the choices for the article_category field from the database + self.fields["article_category"].queryset = ( + NewsAndArticlesCategory.objects.filter(deleted=False) + ) + + if instance is None: + # This is an add operation, exclude the 'active' field + self.fields.pop("active") + + +class AboutUsForm(forms.ModelForm): + class Meta: + model = Organization + fields = ["about_us"] + labels = {"about_us": "Enter information about your organization:"} + + +class TermsAndConditionForm(forms.ModelForm): + class Meta: + model = Organization + fields = ["terms_condition"] + labels = {"terms_condition": "Enter Terms and Conditions:"} + + +class PrivacyPolicyForm(forms.ModelForm): + class Meta: + model = Organization + fields = ["privacy_policy"] + labels = {"privacy_policy": "Enter Privacy Police:"} + + +class FaqCategoryFrom(forms.ModelForm): + class Meta: + model = FaqCategory + fields = ["name"] + labels = {"name": "Category name"} + + +class FaqsForm(forms.ModelForm): + class Meta: + model = Faqs + fields = [ + # "faq_category", + "question", + "answer", + "active", + ] + # labels = {"faq_category": "Category"} + + def __init__(self, *args, **kwargs): + instance = kwargs.get("instance") + super().__init__(*args, **kwargs) + # Fetch the choices for the faq_category field from the database + # self.fields["faq_category"].queryset = FaqCategory.objects.all() + + if instance is None: + # This is an add operation, exclude the 'active' field + self.fields.pop("active") + + +class EducationVideoForm(forms.ModelForm): + published_at = forms.DateTimeField() + withdrawn_at = forms.DateTimeField() + thumbnail = forms.ImageField() + + class Meta: + model = Education + fields = [ + "content_type", + "title", + "description", + "thumbnail", + "video_url", + "tags", + "published_at", + "withdrawn_at", + "active", + ] + + labels = { + "content_type": "Type", + "title": "Title", + "description": "Description", + "thumbnail": "Thumbnail", + "video_url": "Video Url", + "tags": "Tags", + "published_at": "Publish Date", + "withdrawn_at": "Withdrawn Date", + "active": "Active", + } + + def __init__(self, *args, **kwargs): + instance = kwargs.get("instance") + super().__init__(*args, **kwargs) + + self.fields["content_type"].initial = Education.VIDEO + self.fields["content_type"].widget = forms.HiddenInput() + if instance is None: + self.fields.pop("active") + + +class EducationMaterialForm(forms.ModelForm): + published_at = forms.DateTimeField() + withdrawn_at = forms.DateTimeField() + file = forms.FileField() + + class Meta: + model = Education + fields = [ + "content_type", + "title", + "file", + "tags", + "published_at", + "withdrawn_at", + "active", + ] + + labels = { + "content_type": "Type", + "title": "Title", + "file": "Upload File", + "tags": "Tags", + "published_at": "Publish Date", + "withdrawn_at": "Withdrawn Date", + "active": "Active", + } + + def __init__(self, *args, **kwargs): + instance = kwargs.get("instance") + super().__init__(*args, **kwargs) + + self.fields["content_type"].initial = Education.MATERIAL + self.fields["content_type"].widget = forms.HiddenInput() + if instance is None: + self.fields.pop("active") diff --git a/manage_cms/migrations/0001_initial.py b/manage_cms/migrations/0001_initial.py new file mode 100644 index 0000000..2eddf53 --- /dev/null +++ b/manage_cms/migrations/0001_initial.py @@ -0,0 +1,436 @@ +# Generated by Django 5.0.2 on 2024-02-29 07:47 + +import django.core.validators +import django.db.models.deletion +import django_quill.fields +import taggit.managers +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ( + "taggit", + "0006_rename_taggeditem_content_type_object_id_taggit_tagg_content_8fc721_idx", + ), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="Education", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("active", models.BooleanField(default=True)), + ("deleted", models.BooleanField(default=False)), + ("created_on", models.DateTimeField(auto_now_add=True)), + ("modified_on", models.DateTimeField(auto_now=True)), + ( + "content_type", + models.CharField( + choices=[("Video", "Video"), ("Material", "Material")], + max_length=20, + ), + ), + ("title", models.CharField(max_length=255)), + ("description", models.TextField()), + ( + "thumbnail", + models.ImageField(blank=True, null=True, upload_to="education"), + ), + ( + "file", + models.FileField( + blank=True, + null=True, + upload_to="education", + validators=[ + django.core.validators.FileExtensionValidator( + allowed_extensions=["pdf"], + message="Only PDF files are allowed.", + ) + ], + ), + ), + ("video_url", models.URLField(blank=True, null=True)), + ("published_at", models.DateTimeField()), + ("withdrawn_at", models.DateTimeField()), + ( + "created_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="%(class)s_created", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "modified_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="%(class)s_modified", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "tags", + taggit.managers.TaggableManager( + help_text="A comma-separated list of tags.", + through="taggit.TaggedItem", + to="taggit.Tag", + verbose_name="Tags", + ), + ), + ], + options={ + "abstract": False, + }, + ), + migrations.CreateModel( + name="FaqCategory", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("active", models.BooleanField(default=True)), + ("deleted", models.BooleanField(default=False)), + ("created_on", models.DateTimeField(auto_now_add=True)), + ("modified_on", models.DateTimeField(auto_now=True)), + ("name", models.CharField(max_length=255)), + ( + "created_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="%(class)s_created", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "modified_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="%(class)s_modified", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "db_table": "faq_category", + }, + ), + migrations.CreateModel( + name="Faqs", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("active", models.BooleanField(default=True)), + ("deleted", models.BooleanField(default=False)), + ("created_on", models.DateTimeField(auto_now_add=True)), + ("modified_on", models.DateTimeField(auto_now=True)), + ("question", models.TextField(max_length=255)), + ("answer", models.TextField(blank=True, null=True)), + ( + "created_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="%(class)s_created", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "faq_category", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="faqs_category", + to="manage_cms.faqcategory", + ), + ), + ( + "modified_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="%(class)s_modified", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "db_table": "faq", + }, + ), + migrations.CreateModel( + name="NewsAndArticlesCategory", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("active", models.BooleanField(default=True)), + ("deleted", models.BooleanField(default=False)), + ("created_on", models.DateTimeField(auto_now_add=True)), + ("modified_on", models.DateTimeField(auto_now=True)), + ("name", models.CharField(max_length=255)), + ( + "created_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="%(class)s_created", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "modified_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="%(class)s_modified", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "db_table": "news_article_category", + }, + ), + migrations.CreateModel( + name="NewsAndArticles", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("active", models.BooleanField(default=True)), + ("deleted", models.BooleanField(default=False)), + ("created_on", models.DateTimeField(auto_now_add=True)), + ("modified_on", models.DateTimeField(auto_now=True)), + ("title", models.CharField(max_length=255)), + ("small_description", models.TextField(blank=True, null=True)), + ("long_description", django_quill.fields.QuillField()), + ( + "image_url", + models.ImageField(blank=True, null=True, upload_to="news_article"), + ), + ("video_url", models.URLField(blank=True, max_length=2000, null=True)), + ( + "author", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_author", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "created_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="%(class)s_created", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "modified_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="%(class)s_modified", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "tags", + taggit.managers.TaggableManager( + help_text="A comma-separated list of tags.", + through="taggit.TaggedItem", + to="taggit.Tag", + verbose_name="Tags", + ), + ), + ( + "article_category", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="%(class)s_category", + to="manage_cms.newsandarticlescategory", + ), + ), + ], + options={ + "db_table": "news_article", + }, + ), + migrations.CreateModel( + name="Newsletter", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("active", models.BooleanField(default=True)), + ("deleted", models.BooleanField(default=False)), + ("created_on", models.DateTimeField(auto_now_add=True)), + ("modified_on", models.DateTimeField(auto_now=True)), + ("title", models.CharField(max_length=255)), + ("content", django_quill.fields.QuillField()), + ("publication_date", models.DateField(auto_now_add=True)), + ("is_published", models.BooleanField(default=False)), + ( + "created_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="%(class)s_created", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "modified_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="%(class)s_modified", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "db_table": "newsletters", + "ordering": ["-publication_date"], + }, + ), + migrations.CreateModel( + name="Organization", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("active", models.BooleanField(default=True)), + ("deleted", models.BooleanField(default=False)), + ("created_on", models.DateTimeField(auto_now_add=True)), + ("modified_on", models.DateTimeField(auto_now=True)), + ("title", models.CharField(max_length=255)), + ( + "contact_us_email", + models.EmailField( + blank=True, max_length=254, null=True, unique=True + ), + ), + ("instagram_handle", models.URLField(blank=True, null=True)), + ("facebook_handle", models.URLField(blank=True, null=True)), + ("linkedin_handle", models.URLField(blank=True, null=True)), + ( + "logo_image", + models.ImageField( + blank=True, null=True, upload_to="organization/logo" + ), + ), + ( + "favicon_image", + models.ImageField( + blank=True, null=True, upload_to="organization/favicon" + ), + ), + ("website_url", models.URLField(blank=True, null=True)), + ("about_us", django_quill.fields.QuillField()), + ("terms_condition", django_quill.fields.QuillField()), + ("terms_condition_user", django_quill.fields.QuillField()), + ("terms_condition_merchant", django_quill.fields.QuillField()), + ("privacy_policy", django_quill.fields.QuillField()), + ("privacy_policy_user", django_quill.fields.QuillField()), + ("privacy_policy_merchant", django_quill.fields.QuillField()), + ("subscription_agreement", django_quill.fields.QuillField()), + ("license_agreement_user", django_quill.fields.QuillField()), + ("license_agreement_merchant", django_quill.fields.QuillField()), + ( + "created_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="%(class)s_created", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "modified_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="%(class)s_modified", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "db_table": "organization", + }, + ), + ] diff --git a/manage_cms/migrations/__init__.py b/manage_cms/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/manage_cms/models.py b/manage_cms/models.py new file mode 100644 index 0000000..861aa8f --- /dev/null +++ b/manage_cms/models.py @@ -0,0 +1,159 @@ +from django.db import models +from taggit.managers import TaggableManager +from django_quill.fields import QuillField +from accounts.models import BaseModel, IAmPrincipal, IAmPrincipalType +from django.utils import timezone +from django.core.exceptions import ValidationError +from django.core.validators import FileExtensionValidator + + +# Create your models here. +class NewsAndArticlesCategory(BaseModel): + name = models.CharField(max_length=255) + + class Meta: + db_table = "news_article_category" + + def __str__(self): + return self.name + + +class NewsAndArticles(BaseModel): + title = models.CharField(max_length=255) + small_description = models.TextField(blank=True, null=True) + long_description = QuillField() + image_url = models.ImageField(upload_to="news_article", blank=True, null=True) + video_url = models.URLField(max_length=2000, blank=True, null=True) + article_category = models.ForeignKey( + NewsAndArticlesCategory, + related_name="%(class)s_category", + on_delete=models.CASCADE, + ) + author = models.ForeignKey( + IAmPrincipal, + related_name="%(class)s_author", + null=True, + blank=True, + on_delete=models.SET_NULL, + ) + tags = TaggableManager() + + class Meta: + db_table = "news_article" + + def __str__(self): + return self.title + + +class Newsletter(BaseModel): + title = models.CharField(max_length=255) + content = QuillField() + publication_date = models.DateField(auto_now_add=True) + is_published = models.BooleanField(default=False) + + class Meta: + db_table = "newsletters" + ordering = ["-publication_date"] + + def __str__(self): + return self.title + + +class FaqCategory(BaseModel): + name = models.CharField(max_length=255) + + class Meta: + db_table = "faq_category" + + def __str__(self): + return self.name + + +class Faqs(BaseModel): + faq_category = models.ForeignKey( + FaqCategory, related_name="faqs_category", null=True, on_delete=models.SET_NULL + ) + question = models.TextField(max_length=255) + answer = models.TextField(blank=True, null=True) + + class Meta: + db_table = "faq" + + def __str__(self): + return self.question + + +class Organization(BaseModel): + title = models.CharField(max_length=255) + contact_us_email = models.EmailField(unique=True, blank=True, null=True) + instagram_handle = models.URLField(blank=True, null=True) + facebook_handle = models.URLField(blank=True, null=True) + linkedin_handle = models.URLField(blank=True, null=True) + logo_image = models.ImageField(blank=True, null=True, upload_to="organization/logo") + favicon_image = models.ImageField( + blank=True, null=True, upload_to="organization/favicon" + ) + website_url = models.URLField(blank=True, null=True) + about_us = QuillField() + terms_condition = QuillField() + terms_condition_user = QuillField() + terms_condition_merchant = QuillField() + privacy_policy = QuillField() + privacy_policy_user = QuillField() + privacy_policy_merchant = QuillField() + subscription_agreement = QuillField() + license_agreement_user = QuillField() + license_agreement_merchant = QuillField() + + class Meta: + db_table = "organization" + + def __str__(self): + return self.title + + +class Education(BaseModel): + VIDEO = "Video" + MATERIAL = "Material" + + CONTENT_TYPE_CHOICES = ( + (VIDEO, "Video"), + (MATERIAL, "Material"), + ) + content_type = models.CharField(max_length=20, choices=CONTENT_TYPE_CHOICES) + title = models.CharField(max_length=255) + description = models.TextField() + thumbnail = models.ImageField(upload_to="education", null=True, blank=True) + file = models.FileField( + upload_to="education", + null=True, + blank=True, + validators=[ + FileExtensionValidator( + allowed_extensions=["pdf"], message="Only PDF files are allowed." + ) + ] + ) + video_url = models.URLField(null=True, blank=True) + tags = TaggableManager(related_name="education_tags") + published_at = models.DateTimeField() + withdrawn_at = models.DateTimeField() + + def clean(self): + current_date = timezone.now() + # if self.published_at and self.published_at < current_date: + # raise ValidationError("Published date should be in the future.") + + if self.withdrawn_at and self.withdrawn_at < current_date: + raise ValidationError("Withdrawn date should be in the future.") + + if (self.published_at and self.withdrawn_at and self.published_at > self.withdrawn_at): + raise ValidationError( + "Published date should be earlier than withdrawn date." + ) + + def is_publised(self): + current_date = timezone.now() + return self.published_at <= current_date and ( + not self.withdrawn_at or self.withdrawn_at > current_date + ) diff --git a/manage_cms/tests.py b/manage_cms/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/manage_cms/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/manage_cms/urls.py b/manage_cms/urls.py new file mode 100644 index 0000000..b3ef006 --- /dev/null +++ b/manage_cms/urls.py @@ -0,0 +1,52 @@ +from django.urls import path +from . import views +from django.views.generic import TemplateView + + +app_name = 'manage_cms' + +urlpatterns = [ + + path('dashboard/', views.CmsDashboardView.as_view(), name='cms_dashboard'), + + path('news-article/', views.NewsArticleListView.as_view(), name='news_article_list'), + path('news-article/add/', views.NewsArticleCreateOrUpdateView.as_view(), name='news_article_add'), + path('news-article/edit//', views.NewsArticleCreateOrUpdateView.as_view(), name='news_article_edit'), + path('news-article/delete//', views.NewsArticleDeleteView.as_view(), name='news_article_delete'), + path('news-article/category/add/', views.NewsArticleCategoryCreateOrUpdateView.as_view(), name='news_article_category_add'), + path('news-article/category/edit//', views.NewsArticleCategoryCreateOrUpdateView.as_view(), name='news_article_category_edit'), + path('news-article/category/delete//', views.NewsArticleCategoryDeleteView.as_view(), name='news_article_category_delete'), + + path('newsletter/', views.NewsLetterListView.as_view(), name='newsletter_list'), + path('newsletter/add/', views.NewsLetterCreateOrUpdateView.as_view(), name='newsletter_add'), + path('newsletter/edit//', views.NewsArticleCreateOrUpdateView.as_view(), name='news_article_edit'), + path('newsletter/delete//', views.NewsArticleDeleteView.as_view(), name='news_article_delete'), + + + path('about-us/', views.AboutUsView.as_view(), name='about_us_view'), + path('about-us/edit/', views.AboutUsCreateOrUpdateView.as_view(), name='about_us_add'), + + path('terms-condition/', views.TermsConditionView.as_view(), name='terms_and_condition_view'), + path('terms-condition/edit/', views.TermsConditionCreateOrUpdateView.as_view(), name='terms_and_condition_edit'), + + path('faq/', views.FaqListView.as_view(), name='faq_list'), + path('faq/add/', views.FaqCreateOrUpdateView.as_view(), name='faq_add'), + path('faq/edit//', views.FaqCreateOrUpdateView.as_view(), name='faq_edit'), + path('faq/category/add/', views.FaqCategoryCreateOrUpdateView.as_view(), name='faq_category_add'), + path('faq/category/edit//', views.FaqCategoryCreateOrUpdateView.as_view(), name='faq_category_edit'), + + path('privacy-policy/', views.PrivacyPolicyView.as_view(), name='privacy_policy_view'), + path('privacy-policy/edit/', views.PrivacyPolicyCreateOrUpdateView.as_view(), name='privacy_policy_edit'), + + path('testimonial/', views.TestimonialListView.as_view(), name='testimonial_list'), + + path('organization/', views.OrganizationView.as_view(), name='organization_view'), + path('organization/add/', views.OrganizationCreateOrUpdateView.as_view(), name='organization_add'), + + path('education/', views.EducationView.as_view(), name='education_view'), + path('education/video/add/', views.EducationVideoCreateOrUpdateView.as_view(), name='education_add_video'), + path('education/video/edit//', views.EducationVideoCreateOrUpdateView.as_view(), name='education_edit_video'), + path('education/material/add/', views.EducationMaterialCreateOrUpdateView.as_view(), name='education_add_material'), + path('education/material/edit//', views.EducationMaterialCreateOrUpdateView.as_view(), name='education_edit_material'), + +] diff --git a/manage_cms/views.py b/manage_cms/views.py new file mode 100644 index 0000000..fcefb9d --- /dev/null +++ b/manage_cms/views.py @@ -0,0 +1,879 @@ +from typing import Any +from django.db import models +from django.shortcuts import render, get_object_or_404, redirect +from django.contrib.auth.mixins import LoginRequiredMixin +from accounts import resource_action +from django.views import generic +from goodtimes import constants +from django.urls import reverse_lazy +from django.contrib import messages +from .models import ( + Organization, + NewsAndArticles, + NewsAndArticlesCategory, + Newsletter, + Faqs, + FaqCategory, + Education, +) +from .forms import ( + OrganizationForm, + NewsAndArticlesForm, + NewsAndArticleCategoryForm, + AboutUsForm, + TermsAndConditionForm, + PrivacyPolicyForm, + FaqsForm, + FaqCategoryFrom, + EducationVideoForm, + EducationMaterialForm +) + + +class CmsDashboardView(LoginRequiredMixin, generic.TemplateView): + page_name = resource_action.RESOURCE_MANAGE_CMS + template_name = "manage_cms/cms_dashboard.html" + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["page_name"] = self.page_name + return context + + +class NewsArticleListView(LoginRequiredMixin, generic.ListView): + page_name = resource_action.RESOURCE_MANAGE_CMS + resource = resource_action.RESOURCE_MANAGE_CMS + action = resource_action.ACTION_READ + template_name = "manage_cms/news_article_list.html" + model = NewsAndArticles + context_object_name = "news_article_obj" + + def get_queryset(self): + return ( + super() + .get_queryset() + .select_related("article_category") + .prefetch_related("tags") + .filter(deleted=False) + ) + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["page_name"] = self.page_name + context["category_obj"] = NewsAndArticlesCategory.objects.filter(deleted=False) + return context + + +class NewsArticleCreateOrUpdateView(LoginRequiredMixin, generic.View): + # Set the page_name and resource + page_name = resource_action.RESOURCE_MANAGE_CMS + resource = resource_action.RESOURCE_MANAGE_CMS + + # Initialize the action as ACTION_CREATE (can change based on logic) + action = resource_action.ACTION_CREATE # Default action + + template_name = "manage_cms/news_article_add.html" + model = NewsAndArticles + form_class = NewsAndArticlesForm + success_url = reverse_lazy("manage_cms:news_article_list") + error_message = "An error occurred while saving the data." + + # Determine the success message dynamically based on whether it's an update or create + def get_success_message(self): + self.success_message = ( + constants.RECORD_CREATED if not self.object else constants.RECORD_UPDATED + ) + return self.success_message + + # Get the object (if exists) based on URL parameter 'pk' + def get_object(self): + pk = self.kwargs.get("pk") + return get_object_or_404(self.model, pk=pk) if pk else None + + # Add page_name and operation to the context + def get_context_data(self, **kwargs): + context = { + "page_name": self.page_name, + "operation": "Add" if not self.object else "Edit", + } + context.update(kwargs) # Include any additional context data passed to the view + return context + + def get(self, request, *args, **kwargs): + self.object = self.get_object() + + # If an object is found, change action to ACTION_UPDATE + if self.object is not None: + self.action = resource_action.ACTION_UPDATE + print("get method of article is called") + form = self.form_class(instance=self.object) + context = self.get_context_data(form=form) + return render(request, self.template_name, context=context) + + def post(self, request, *args, **kwargs): + self.object = self.get_object() + + # If an object is found, change action to ACTION_UPDATE + if self.object is not None: + self.action = resource_action.ACTION_UPDATE + print("post method is called") + form = self.form_class(request.POST, request.FILES, instance=self.object) + print("request with files", request.FILES) + if not form.is_valid(): + print(form.errors) + context = self.get_context_data(form=form) + return render(request, self.template_name, context=context) + form.save() + messages.success(self.request, self.get_success_message()) + return redirect(self.success_url) + + +class NewsArticleDeleteView(LoginRequiredMixin, generic.View): + page_name = resource_action.RESOURCE_MANAGE_CMS + resource = resource_action.RESOURCE_MANAGE_CMS + action = resource_action.ACTION_DELETE + model = NewsAndArticles + success_url = reverse_lazy("manage_cms:news_article_list") + success_message = constants.RECORD_DELETED + error_message = constants.RECORD_NOT_FOUND + + def get(self, request, pk): + try: + type_obj = self.model.objects.get(id=pk) + type_obj.deleted = True + type_obj.save() + messages.success(request, self.success_message) + except self.model.DoesNotExist: + messages.success(request, self.error_message) + + return redirect(self.success_url) + + +class NewsArticleCategoryCreateOrUpdateView(LoginRequiredMixin, generic.View): + # Set the page_name and resource + page_name = resource_action.RESOURCE_MANAGE_CMS + resource = resource_action.RESOURCE_MANAGE_CMS + + # Initialize the action as ACTION_CREATE (can change based on logic) + action = resource_action.ACTION_CREATE # Default action + + template_name = "manage_cms/news_article_category_add.html" + model = NewsAndArticlesCategory + form_class = NewsAndArticleCategoryForm + success_url = reverse_lazy("manage_cms:news_article_list") + error_message = "An error occurred while saving the data." + + # Determine the success message dynamically based on whether it's an update or create + def get_success_message(self): + self.success_message = ( + constants.RECORD_CREATED if not self.object else constants.RECORD_UPDATED + ) + return self.success_message + + # Get the object (if exists) based on URL parameter 'pk' + def get_object(self): + pk = self.kwargs.get("pk") + return get_object_or_404(self.model, pk=pk) if pk else None + + # Add page_name and operation to the context + def get_context_data(self, **kwargs): + context = { + "page_name": self.page_name, + "operation": "Add" if not self.object else "Edit", + } + context.update(kwargs) # Include any additional context data passed to the view + return context + + def get(self, request, *args, **kwargs): + print("Get Action called:") + self.object = self.get_object() + + # If an object is found, change action to ACTION_UPDATE + if self.object is not None: + self.action = resource_action.ACTION_UPDATE + + form = self.form_class(instance=self.object) + context = self.get_context_data(form=form) + return render(request, self.template_name, context=context) + + def post(self, request, *args, **kwargs): + self.object = self.get_object() + + # If an object is found, change action to ACTION_UPDATE + if self.object is not None: + self.action = resource_action.ACTION_UPDATE + + form = self.form_class(request.POST, instance=self.object) + if not form.is_valid(): + print(form.errors) + context = self.get_context_data(form=form) + return render(request, self.template_name, context=context) + + form.save() + messages.success(self.request, self.get_success_message()) + return redirect(self.success_url) + + +class NewsArticleCategoryDeleteView(LoginRequiredMixin, generic.View): + page_name = resource_action.RESOURCE_MANAGE_CMS + resource = resource_action.RESOURCE_MANAGE_CMS + action = resource_action.ACTION_DELETE + model = NewsAndArticlesCategory + success_url = reverse_lazy("manage_cms:news_article_list") + success_message = constants.RECORD_DELETED + error_message = constants.RECORD_NOT_FOUND + + def get(self, request, pk): + try: + type_obj = self.model.objects.get(id=pk) + type_obj.deleted = True + type_obj.save() + messages.success(request, self.success_message) + except self.model.DoesNotExist: + messages.success(request, self.error_message) + + return redirect(self.success_url) + + +class NewsLetterListView(LoginRequiredMixin, generic.TemplateView): + page_name = resource_action.RESOURCE_MANAGE_CMS + resource = resource_action.RESOURCE_MANAGE_CMS + action = resource_action.ACTION_READ + template_name = "manage_cms/newsletter_list.html" + + # Determine the success message dynamically based on whether it's an update or create + def get_success_message(self): + self.success_message = ( + constants.RECORD_CREATED if not self.object else constants.RECORD_UPDATED + ) + return self.success_message + + # Get the object (if exists) based on URL parameter 'pk' + def get_object(self): + pk = self.kwargs.get("pk") + return get_object_or_404(self.model, pk=pk) if pk else None + + # Add page_name and operation to the context + def get_context_data(self, **kwargs): + context = { + "page_name": self.page_name, + "operation": "Add" if not self.object else "Edit", + } + context.update(kwargs) # Include any additional context data passed to the view + return context + + def get(self, request, *args, **kwargs): + self.object = self.get_object() + + # If an object is found, change action to ACTION_UPDATE + if self.object is not None: + self.action = resource_action.ACTION_UPDATE + + form = self.form_class(instance=self.object) + context = self.get_context_data(form=form) + return render(request, self.template_name, context=context) + + def post(self, request, *args, **kwargs): + self.object = self.get_object() + + # If an object is found, change action to ACTION_UPDATE + if self.object is not None: + self.action = resource_action.ACTION_UPDATE + + form = self.form_class(request.POST, instance=self.object) + if not form.is_valid(): + print(form.errors) + context = self.get_context_data(form=form) + return render(request, self.template_name, context=context) + form.save() + messages.success(self.request, self.get_success_message()) + return redirect(self.success_url) + + +class NewsLetterCreateOrUpdateView(LoginRequiredMixin, generic.TemplateView): + page_name = resource_action.RESOURCE_MANAGE_CMS + template_name = "manage_cms/newsletter_add.html" + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["page_name"] = self.page_name + return context + + +class AboutUsView(LoginRequiredMixin, generic.DetailView): + page_name = resource_action.RESOURCE_MANAGE_CMS + template_name = "manage_cms/about_us_view.html" + model = Organization + context_object_name = "organization" + + def get_object(self, queryset=None): + return self.model.objects.only("about_us").first() + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["page_name"] = self.page_name + return context + + +class AboutUsCreateOrUpdateView(LoginRequiredMixin, generic.View): + # Set the page_name and resource + page_name = resource_action.RESOURCE_MANAGE_CMS + resource = resource_action.RESOURCE_MANAGE_CMS + + # Initialize the action as ACTION_CREATE (can change based on logic) + action = resource_action.ACTION_CREATE # Default action + + template_name = "manage_cms/about_us_add.html" + model = Organization + form_class = AboutUsForm + success_url = reverse_lazy("manage_cms:about_us_view") + error_message = "An error occurred while saving the data." + + # Determine the success message dynamically based on whether it's an update or create + def get_success_message(self): + self.success_message = ( + constants.RECORD_CREATED if not self.object else constants.RECORD_UPDATED + ) + return self.success_message + + # Get the object (if exists) based on URL parameter 'pk' + def get_object(self): + return self.model.objects.only("about_us").first() + + # Add page_name and operation to the context + def get_context_data(self, **kwargs): + context = { + "page_name": self.page_name, + "operation": "Add" if not self.object else "Edit", + } + context.update(kwargs) # Include any additional context data passed to the view + return context + + def get(self, request, *args, **kwargs): + self.object = self.get_object() + + # If an object is found, change action to ACTION_UPDATE + if self.object is not None: + self.action = resource_action.ACTION_UPDATE + + form = self.form_class(instance=self.object) + context = self.get_context_data(form=form) + return render(request, self.template_name, context=context) + + def post(self, request, *args, **kwargs): + self.object = self.get_object() + + # If an object is found, change action to ACTION_UPDATE + if self.object is not None: + self.action = resource_action.ACTION_UPDATE + + form = self.form_class(request.POST, instance=self.object) + if not form.is_valid(): + print(form.errors) + context = self.get_context_data(form=form) + return render(request, self.template_name, context=context) + + form.save() + messages.success(self.request, self.get_success_message()) + return redirect(self.success_url) + + +class TermsConditionView(LoginRequiredMixin, generic.DetailView): + page_name = resource_action.RESOURCE_MANAGE_CMS + resource = resource_action.RESOURCE_MANAGE_CMS + action = resource_action.ACTION_DELETE + template_name = "manage_cms/terms_and_condition_view.html" + model = Organization + context_object_name = "organization" + + def get_object(self, queryset=None): + return self.model.objects.only("terms_condition").first() + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["page_name"] = self.page_name + return context + + +class TermsConditionCreateOrUpdateView(LoginRequiredMixin, generic.View): + # Set the page_name and resource + page_name = resource_action.RESOURCE_MANAGE_CMS + resource = resource_action.RESOURCE_MANAGE_CMS + + # Initialize the action as ACTION_CREATE (can change based on logic) + action = resource_action.ACTION_CREATE # Default action + + template_name = "manage_cms/terms_and_condition_edit.html" + model = Organization + form_class = TermsAndConditionForm + success_url = reverse_lazy("manage_cms:terms_and_condition_view") + error_message = "An error occurred while saving the data." + + # Determine the success message dynamically based on whether it's an update or create + def get_success_message(self): + self.success_message = ( + constants.RECORD_CREATED if not self.object else constants.RECORD_UPDATED + ) + return self.success_message + + # Get the object (if exists) based on URL parameter 'pk' + def get_object(self): + return self.model.objects.only("terms_condition").first() + + # Add page_name and operation to the context + def get_context_data(self, **kwargs): + context = { + "page_name": self.page_name, + "operation": "Add" if not self.object else "Edit", + } + context.update(kwargs) # Include any additional context data passed to the view + return context + + def get(self, request, *args, **kwargs): + self.object = self.get_object() + + # If an object is found, change action to ACTION_UPDATE + if self.object is not None: + self.action = resource_action.ACTION_UPDATE + + form = self.form_class(instance=self.object) + context = self.get_context_data(form=form) + return render(request, self.template_name, context=context) + + def post(self, request, *args, **kwargs): + self.object = self.get_object() + + # If an object is found, change action to ACTION_UPDATE + if self.object is not None: + self.action = resource_action.ACTION_UPDATE + + form = self.form_class(request.POST, instance=self.object) + if not form.is_valid(): + print(form.errors) + context = self.get_context_data(form=form) + return render(request, self.template_name, context=context) + + form.save() + messages.success(self.request, self.get_success_message()) + return redirect(self.success_url) + + +class FaqListView(LoginRequiredMixin, generic.ListView): + page_name = resource_action.RESOURCE_MANAGE_CMS + resource = resource_action.RESOURCE_MANAGE_CMS + action = resource_action.ACTION_READ + template_name = "manage_cms/faq.html" + model = Faqs + context_object_name = "faqs_obj" + + def get_queryset(self): + return ( + super() + .get_queryset() + .filter(deleted=False) + ) + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["page_name"] = self.page_name + context["faq_category_obj"] = FaqCategory.objects.all() + return context + + +class FaqCreateOrUpdateView(LoginRequiredMixin, generic.View): + # Set the page_name and resource + page_name = resource_action.RESOURCE_MANAGE_CMS + resource = resource_action.RESOURCE_MANAGE_CMS + + # Initialize the action as ACTION_CREATE (can change based on logic) + action = resource_action.ACTION_CREATE # Default action + + template_name = "manage_cms/faq_add.html" + model = Faqs + form_class = FaqsForm + success_url = reverse_lazy("manage_cms:faq_list") + error_message = "An error occurred while saving the data." + + # Determine the success message dynamically based on whether it's an update or create + def get_success_message(self): + self.success_message = ( + constants.RECORD_CREATED if not self.object else constants.RECORD_UPDATED + ) + return self.success_message + + # Get the object (if exists) based on URL parameter 'pk + def get_object(self): + pk = self.kwargs.get("pk") + return get_object_or_404(self.model, pk=pk) if pk else None + + # Add page_name and operation to the context + def get_context_data(self, **kwargs): + context = { + "page_name": self.page_name, + "operation": "Add" if not self.object else "Edit", + } + context.update(kwargs) # Include any additional context data passed to the view + return context + + def get(self, request, *args, **kwargs): + self.object = self.get_object() + + # If an object is found, change action to ACTION_UPDATE + if self.object is not None: + self.action = resource_action.ACTION_UPDATE + + form = self.form_class(instance=self.object) + context = self.get_context_data(form=form) + return render(request, self.template_name, context=context) + + def post(self, request, *args, **kwargs): + print("Request data: ", request.POST) + self.object = self.get_object() + + # If an object is found, change action to ACTION_UPDATE + if self.object is not None: + self.action = resource_action.ACTION_UPDATE + + form = self.form_class(request.POST, instance=self.object) + if not form.is_valid(): + print(form.errors) + context = self.get_context_data(form=form) + return render(request, self.template_name, context=context) + + form.save() + messages.success(self.request, self.get_success_message()) + return redirect(self.success_url) + + +class FaqCategoryCreateOrUpdateView(LoginRequiredMixin, generic.View): + # Set the page_name and resource + page_name = resource_action.RESOURCE_MANAGE_CMS + resource = resource_action.RESOURCE_MANAGE_CMS + + # Initialize the action as ACTION_CREATE (can change based on logic) + action = resource_action.ACTION_CREATE # Default action + + template_name = "manage_cms/faq_category_add.html" + model = FaqCategory + form_class = FaqCategoryFrom + success_url = reverse_lazy("manage_cms:faq_list") + error_message = "An error occurred while saving the data." + + # Determine the success message dynamically based on whether it's an update or create + def get_success_message(self): + self.success_message = ( + constants.RECORD_CREATED if not self.object else constants.RECORD_UPDATED + ) + return self.success_message + + # Get the object (if exists) based on URL parameter 'pk + def get_object(self): + pk = self.kwargs.get("pk") + return get_object_or_404(self.model, pk=pk) if pk else None + + # Add page_name and operation to the context + def get_context_data(self, **kwargs): + context = { + "page_name": self.page_name, + "operation": "Add" if not self.object else "Edit", + } + context.update(kwargs) # Include any additional context data passed to the view + return context + + def get(self, request, *args, **kwargs): + print("Get Action called:") + self.object = self.get_object() + + # If an object is found, change action to ACTION_UPDATE + if self.object is not None: + self.action = resource_action.ACTION_UPDATE + + form = self.form_class(instance=self.object) + context = self.get_context_data(form=form) + return render(request, self.template_name, context=context) + + def post(self, request, *args, **kwargs): + print("Request data: ", request.POST) + self.object = self.get_object() + + # If an object is found, change action to ACTION_UPDATE + if self.object is not None: + self.action = resource_action.ACTION_UPDATE + + form = self.form_class(request.POST, instance=self.object) + if not form.is_valid(): + print(form.errors) + context = self.get_context_data(form=form) + return render(request, self.template_name, context=context) + + form.save() + messages.success(self.request, self.get_success_message()) + return redirect(self.success_url) + + +class PrivacyPolicyView(LoginRequiredMixin, generic.DetailView): + page_name = resource_action.RESOURCE_MANAGE_CMS + resource = resource_action.RESOURCE_MANAGE_CMS + action = resource_action.ACTION_READ + template_name = "manage_cms/privacy_policy_view.html" + model = Organization + context_object_name = "organization" + + def get_object(self, queryset=None): + return self.model.objects.only("privacy_policy").first() + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["page_name"] = self.page_name + return context + + +class PrivacyPolicyCreateOrUpdateView(LoginRequiredMixin, generic.View): + # Set the page_name and resource + page_name = resource_action.RESOURCE_MANAGE_CMS + resource = resource_action.RESOURCE_MANAGE_CMS + + # Initialize the action as ACTION_CREATE (can change based on logic) + action = resource_action.ACTION_CREATE # Default action + + template_name = "manage_cms/privacy_policy_edit.html" + model = Organization + form_class = PrivacyPolicyForm + success_url = reverse_lazy("manage_cms:privacy_policy_view") + error_message = "An error occurred while saving the data." + + # Determine the success message dynamically based on whether it's an update or create + def get_success_message(self): + self.success_message = ( + constants.RECORD_CREATED if not self.object else constants.RECORD_UPDATED + ) + return self.success_message + + # Get the object (if exists) based on URL parameter 'pk' + def get_object(self): + return self.model.objects.only("privacy_policy").first() + + # Add page_name and operation to the context + def get_context_data(self, **kwargs): + context = { + "page_name": self.page_name, + "operation": "Add" if not self.object else "Edit", + } + context.update(kwargs) # Include any additional context data passed to the view + return context + + def get(self, request, *args, **kwargs): + self.object = self.get_object() + + # If an object is found, change action to ACTION_UPDATE + if self.object is not None: + self.action = resource_action.ACTION_UPDATE + + form = self.form_class(instance=self.object) + context = self.get_context_data(form=form) + return render(request, self.template_name, context=context) + + def post(self, request, *args, **kwargs): + self.object = self.get_object() + + # If an object is found, change action to ACTION_UPDATE + if self.object is not None: + self.action = resource_action.ACTION_UPDATE + + form = self.form_class(request.POST, instance=self.object) + if not form.is_valid(): + print(form.errors) + context = self.get_context_data(form=form) + return render(request, self.template_name, context=context) + form.save() + messages.success(self.request, self.get_success_message()) + return redirect(self.success_url) + + +class TestimonialListView(LoginRequiredMixin, generic.TemplateView): + page_name = resource_action.RESOURCE_MANAGE_CMS + template_name = "manage_cms/testimonial_list.html" + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["page_name"] = self.page_name + return context + + +class OrganizationView(LoginRequiredMixin, generic.DetailView): + page_name = resource_action.RESOURCE_MANAGE_CMS + resource = resource_action.RESOURCE_MANAGE_CMS + action = resource_action.ACTION_READ + model = Organization + template_name = "manage_cms/organization_view.html" + context_object_name = "organization_obj" + + def get_object(self, queryset=None): + return self.model.objects.first() + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["page_name"] = self.page_name + return context + + +class OrganizationCreateOrUpdateView(LoginRequiredMixin, generic.View): + # Set the page_name and resource + page_name = resource_action.RESOURCE_MANAGE_CMS + resource = resource_action.RESOURCE_MANAGE_CMS + + # Initialize the action as ACTION_CREATE (can change based on logic) + action = resource_action.ACTION_CREATE # Default action + + template_name = "manage_cms/organization_add.html" + model = Organization + form_class = OrganizationForm + success_url = reverse_lazy("manage_cms:organization_view") + error_message = "An error occurred while saving the data." + + # Determine the success message dynamically based on whether it's an update or create + def get_success_message(self): + self.success_message = ( + constants.RECORD_CREATED if not self.object else constants.RECORD_UPDATED + ) + return self.success_message + + # Get the object (if exists) based on URL parameter 'pk' + def get_object(self): + return self.model.objects.first() + + # Add page_name and operation to the context + def get_context_data(self, **kwargs): + context = { + "page_name": self.page_name, + "operation": "Add" if not self.object else "Edit", + } + context.update(kwargs) # Include any additional context data passed to the view + return context + + def get(self, request, *args, **kwargs): + self.object = self.get_object() + + # If an object is found, change action to ACTION_UPDATE + if self.object is not None: + self.action = resource_action.ACTION_UPDATE + + form = self.form_class(instance=self.object) + context = self.get_context_data(form=form) + return render(request, self.template_name, context=context) + + def post(self, request, *args, **kwargs): + self.object = self.get_object() + + # If an object is found, change action to ACTION_UPDATE + if self.object is not None: + self.action = resource_action.ACTION_UPDATE + + form = self.form_class(request.POST, instance=self.object) + if not form.is_valid(): + print(form.errors) + context = self.get_context_data(form=form) + return render(request, self.template_name, context=context) + form.save() + messages.success(self.request, self.get_success_message()) + return redirect(self.success_url) + + +class EducationView(LoginRequiredMixin, generic.ListView): + page_name = resource_action.RESOURCE_MANAGE_CMS + resource = resource_action.RESOURCE_MANAGE_CMS + action = resource_action.ACTION_READ + model = Education + template_name = "manage_cms/education_view.html" + context_object_name = "education_obj" + + def get_queryset(self): + return ( + super() + .get_queryset() + .prefetch_related("tags") + .filter(deleted=False) + ) + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["page_name"] = self.page_name + context["video_obj"] = self.get_queryset().filter(content_type=Education.VIDEO) + context["material_obj"] = self.get_queryset().filter(content_type=Education.MATERIAL) + print("video data", context["video_obj"]) + return context + + +class EducationCreateOrUpdateView(LoginRequiredMixin, generic.View): + # Set the page_name and resource + page_name = resource_action.RESOURCE_MANAGE_CMS + resource = resource_action.RESOURCE_MANAGE_CMS + + # Initialize the action as ACTION_CREATE (can change based on logic) + action = resource_action.ACTION_CREATE # Default action + page_title = None + + template_name = "manage_cms/education_add.html" + model = Education + form_class = None + success_url = reverse_lazy("manage_cms:education_view") + + # Determine the success message dynamically based on whether it's an update or create + def get_success_message(self): + self.success_message = ( + constants.RECORD_CREATED if not self.object else constants.RECORD_UPDATED + ) + return self.success_message + + # Get the object (if exists) based on URL parameter 'pk + def get_object(self): + pk = self.kwargs.get("pk") + return get_object_or_404(self.model, pk=pk) if pk else None + + # Add page_name and operation to the context + def get_context_data(self, **kwargs): + context = { + "page_name": self.page_name, + "page_title": self.page_title, + "operation": "Add" if not self.object else "Edit", + } + context.update(kwargs) # Include any additional context data passed to the view + return context + + def get(self, request, *args, **kwargs): + self.object = self.get_object() + + # If an object is found, change action to ACTION_UPDATE + if self.object is not None: + self.action = resource_action.ACTION_UPDATE + + form = self.form_class(instance=self.object) + context = self.get_context_data(form=form) + return render(request, self.template_name, context=context) + + def post(self, request, *args, **kwargs): + self.object = self.get_object() + # print("response of image file", request.POST, request.FILES) + + # If an object is found, change action to ACTION_UPDATE + if self.object is not None: + self.action = resource_action.ACTION_UPDATE + print(f"published date {request.POST.get('published_at')}") + form = self.form_class(request.POST, request.FILES, instance=self.object) + if not form.is_valid(): + print(form.errors) + context = self.get_context_data(form=form) + return render(request, self.template_name, context=context) + form.save() + messages.success(self.request, self.get_success_message()) + return redirect(self.success_url) + + +class EducationVideoCreateOrUpdateView(EducationCreateOrUpdateView): + page_title = "Video" + form_class = EducationVideoForm + + +class EducationMaterialCreateOrUpdateView(EducationCreateOrUpdateView): + page_title = "Material" + form_class = EducationMaterialForm diff --git a/manage_communications/__init__.py b/manage_communications/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/manage_communications/admin.py b/manage_communications/admin.py new file mode 100644 index 0000000..ca6dd62 --- /dev/null +++ b/manage_communications/admin.py @@ -0,0 +1,31 @@ +from django.contrib import admin +from .models import TicketIssueType, TicketAttachment, Tickets + + +class TicketIssueTypeAdmin(admin.ModelAdmin): + list_display = ("id", "name") + search_fields = ("name",) + + +class TicketAttachmentAdmin(admin.ModelAdmin): + list_display = ("id", "image") + search_fields = ("image",) + + +class TicketsAdmin(admin.ModelAdmin): + list_display = ( + "id", + "issuetype", + "principal", + "subject", + "ticket_status", + "is_stopped", + ) + list_filter = ("issuetype", "ticket_status", "is_stopped") + search_fields = ("subject", "description") + filter_horizontal = ("attachments",) # For the many-to-many relationship + + +admin.site.register(TicketIssueType, TicketIssueTypeAdmin) +admin.site.register(TicketAttachment, TicketAttachmentAdmin) +admin.site.register(Tickets, TicketsAdmin) diff --git a/manage_communications/api/serializers.py b/manage_communications/api/serializers.py new file mode 100644 index 0000000..8a13df7 --- /dev/null +++ b/manage_communications/api/serializers.py @@ -0,0 +1,84 @@ +import re + +from rest_framework import serializers +from accounts.models import IAmPrincipal +from goodtimes import constants +from manage_communications.models import ContactUs, Feedback, TicketIssueType, TicketAttachment, Tickets +from goodtimes import date_utils + +class ContactUsSerializer(serializers.ModelSerializer): + class Meta: + model = ContactUs + fields = "__all__" + +class TicketIssueTypeSerializer(serializers.ModelSerializer): + class Meta: + model = TicketIssueType + fields = ["id", "name"] + +class TicketAttachmentSerializer(serializers.ModelSerializer): + class Meta: + model = TicketAttachment + fields = ["image"] + + # def get_image_url(self, obj, field_name, request): + # image_field = getattr(obj, field_name) + # if image_field: + # return request.build_absolute_uri(image_field.url) + # return "" + + # def to_representation(self, instance): + # data = super().to_representation(instance) + # request = self.context.get("request") + # data["image"] = self.get_image_url(instance, "image", request) + # return data + +class TicketStatusSerializer(serializers.Serializer): + ticket_status = serializers.ChoiceField(choices=Tickets.TICKET_STATUS) + +class TicketsSerializer(serializers.ModelSerializer): + issuetype = serializers.PrimaryKeyRelatedField(queryset=TicketIssueType.objects.all()) + attachments = TicketAttachmentSerializer(many=True, read_only=True) + created_on = serializers.SerializerMethodField() + + class Meta: + model = Tickets + fields = [ + "id", + "issuetype", + "principal", + "subject", + "description", + "ticket_status", + "attachments", + "is_stopped", + "created_on", + ] + + def get_created_on(self, obj): + return date_utils.format_date_to_string(obj.created_on) + +class FeedbackSerializer(serializers.ModelSerializer): + RATING_CHOICES = Feedback.RATING_CHOICES + + rating = serializers.ChoiceField(choices=RATING_CHOICES) + feedback_reaction = serializers.SerializerMethodField() + + class Meta: + model = Feedback + fields = ['principal', 'email', 'comment', 'rating', 'feedback_reaction'] + + def get_feedback_reaction(self, obj): + rating = obj.rating + if rating == 1: + return Feedback.VERY_BAD + elif rating == 2: + return Feedback.POOR + elif rating == 3: + return Feedback.MEDIUM + elif rating == 4: + return Feedback.GOOD + elif rating == 5: + return Feedback.EXCELLENT + else: + return None diff --git a/manage_communications/api/urls.py b/manage_communications/api/urls.py new file mode 100644 index 0000000..cb79dfd --- /dev/null +++ b/manage_communications/api/urls.py @@ -0,0 +1,15 @@ +from django.urls import path +from . import views + +app_name = "manage_communication_api" + +urlpatterns = [ + + path('contact-us/', views.ContactUsView.as_view(), name='contact_us_mobile'), + path('ticket/', views.TicketView.as_view(), name='ticket'), + path('ticket/issue_category/', views.TicketCategoryView.as_view(), name='ticket_issue'), + path('ticket/stop//', views.TicketStopView.as_view(), name='ticket_stop'), + path('feedback/', views.FeedbackView.as_view(), name='feedback_mobile'), + + +] diff --git a/manage_communications/api/views.py b/manage_communications/api/views.py new file mode 100644 index 0000000..c2fe6cc --- /dev/null +++ b/manage_communications/api/views.py @@ -0,0 +1,165 @@ +from django.db import transaction +from django.utils import timezone +from rest_framework import status +from rest_framework.views import APIView +from . import serializers +from accounts.models import IAmPrincipal +from ..models import Tickets, TicketAttachment, TicketIssueType +from goodtimes import constants +from goodtimes.services import SMSError, SMSService +from manage_communications import models +# from nifty11_project.services import SMSError, SMSService +from goodtimes.utils import ApiResponse +from rest_framework.permissions import AllowAny, IsAuthenticated +from rest_framework_simplejwt.authentication import JWTAuthentication +from datetime import datetime + + +class ContactUsView(APIView): + + authentication_classes = [] + permission_classes = [] + + def post(self, request): + + print("request.data: ", request.data) + + serializer = serializers.ContactUsSerializer(data=request.data) + + serializer.is_valid(raise_exception=True) + + serializer.save() + + return ApiResponse.success(data=serializer.data, message=constants.SUCCESS) + +class TicketView(APIView): + serializer_class = serializers.TicketsSerializer + model = Tickets + authentication_classes = [JWTAuthentication] + permission_classes = [IsAuthenticated] + + def get_queryset(self): + return Tickets.objects.filter(deleted=False) + + def get(self, request): + month = request.GET.get("month") + status_filter = request.GET.get("status") + + tickets = self.get_queryset() + + if month: + try: + month_date = datetime.strptime(month, "%Y-%m") + print(f"month_Date is {month_date} , month is {month_date.month} , year is {month_date.year}, ticket is {tickets}") + tickets = tickets.filter(created_on__year=month_date.year) + except ValueError: + return ApiResponse.error(message="Invalid date format. Use YYYY-MM", errors="Invalid date format. Use YYYY-MM") + + if status_filter: + tickets = tickets.filter(ticket_status=status_filter) + + serializer = self.serializer_class(tickets, many=True) + ticket_status = [Tickets.REQUESTED, Tickets.VIEWED, Tickets.IN_PROGRESS, Tickets.RESOLVED] + print(f"ticket status is {ticket_status}") + data = { + "ticket_status": ticket_status, + "ticket": serializer.data + } + return ApiResponse.success(message=constants.SUCCESS, data=data) + + def post(self, request): + data = request.data.copy() + print(f"your data is {request.data}") + attachments_data = data.pop("attachments", []) + serializer = self.serializer_class(data=request.data) + + if not serializer.is_valid(): + error_response = { + "status": status.HTTP_400_BAD_REQUEST, + "message": constants.VALIDATION_ERROR, + "errors": serializer.errors, + } + return ApiResponse.error(**error_response) + + try: + ticket = serializer.save(principal=request.user) + attachments = [] + for attachment_data in attachments_data: + attachment = TicketAttachment(image=attachment_data) + attachment.save() + attachments.append(attachment) + + ticket.attachments.add(*attachments) + except Exception as e: + error_response = { + "status": status.HTTP_500_INTERNAL_SERVER_ERROR, + "message": constants.SOMETHING_WRONG, + "errors": str(e), + } + return ApiResponse.error(**error_response) + + return ApiResponse.success(message=constants.SUCCESS) + + +class TicketCategoryView(APIView): + model = TicketIssueType + serializer_class = serializers.TicketIssueTypeSerializer + authentication_classes = [JWTAuthentication] + permission_classes = [IsAuthenticated] + + def get(self, request): + issue_type = self.model.objects.filter(deleted=False) + print(f"issue type {issue_type}") + serializer = self.serializer_class(issue_type, many=True) + return ApiResponse.success(message=constants.SUCCESS, data=serializer.data) + + +class TicketStopView(APIView): + model = Tickets + authentication_classes = [JWTAuthentication] + permission_classes = [IsAuthenticated] + + def get(self, request, pk): + try: + ticket = self.model.objects.get(pk=pk) + ticket.stop_ticket() + return ApiResponse.success(message=constants.SUCCESS) + except Exception as e: + error_message = { + "status": status.HTTP_404_NOT_FOUND, + "message": constants.RECORD_NOT_FOUND, + "errors": str(e) + } + return ApiResponse.error(**error_message) + + +class FeedbackView(APIView): + serializer_class = serializers.FeedbackSerializer + authentication_classes = [JWTAuthentication] + permission_classes = [IsAuthenticated] + + def post(self, request): + data = request.data.copy() + + serializer = self.serializer_class(data=request.data) + + if not serializer.is_valid(): + error_response = { + "status": status.HTTP_400_BAD_REQUEST, + "message": constants.VALIDATION_ERROR, + "errors": serializer.errors, + } + return ApiResponse.error(**error_response) + + try: + serializer.save(principal=request.user) + except Exception as e: + error_response = { + "status": status.HTTP_500_INTERNAL_SERVER_ERROR, + "message": constants.SOMETHING_WRONG, + "errors": str(e), + } + + return ApiResponse.error(**error_response) + + return ApiResponse.success(message=constants.SUCCESS) diff --git a/manage_communications/apps.py b/manage_communications/apps.py new file mode 100644 index 0000000..a013a59 --- /dev/null +++ b/manage_communications/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class ManageCommunicationsConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'manage_communications' diff --git a/manage_communications/migrations/0001_initial.py b/manage_communications/migrations/0001_initial.py new file mode 100644 index 0000000..0f7d29f --- /dev/null +++ b/manage_communications/migrations/0001_initial.py @@ -0,0 +1,320 @@ +# Generated by Django 5.0.2 on 2024-02-29 07:47 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="ContactUs", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("active", models.BooleanField(default=True)), + ("deleted", models.BooleanField(default=False)), + ("created_on", models.DateTimeField(auto_now_add=True)), + ("modified_on", models.DateTimeField(auto_now=True)), + ("name", models.CharField(max_length=100, null=True)), + ("email_address", models.EmailField(max_length=254)), + ("mobile_number", models.CharField(max_length=15)), + ("subject", models.CharField(max_length=200)), + ("message", models.TextField()), + ("reply", models.TextField(blank=True, null=True)), + ( + "created_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="%(class)s_created", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "modified_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="%(class)s_modified", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "db_table": "contact_us", + }, + ), + migrations.CreateModel( + name="Feedback", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("active", models.BooleanField(default=True)), + ("deleted", models.BooleanField(default=False)), + ("created_on", models.DateTimeField(auto_now_add=True)), + ("modified_on", models.DateTimeField(auto_now=True)), + ( + "email", + models.EmailField( + blank=True, + help_text="Email address of the feedback provider", + max_length=254, + null=True, + ), + ), + ("comment", models.TextField(help_text="Feedback comment")), + ( + "rating", + models.PositiveSmallIntegerField( + choices=[ + (1, "1 Star"), + (2, "2 Stars"), + (3, "3 Stars"), + (4, "4 Stars"), + (5, "5 Stars"), + ], + help_text="Rating provided by the user", + ), + ), + ( + "feedback_reaction", + models.CharField( + blank=True, + choices=[ + ("Very Bad", "Very Bad"), + ("Poor", "Poor"), + ("Medium", "Medium"), + ("Good", "Good"), + ("Excellent", "Excellent"), + ], + help_text="Reaction associated with the feedback", + max_length=20, + null=True, + ), + ), + ( + "created_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="%(class)s_created", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "modified_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="%(class)s_modified", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "principal", + models.ForeignKey( + blank=True, + help_text="User associated with this feedback", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="feedbacks", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "db_table": "feedback", + }, + ), + migrations.CreateModel( + name="TicketAttachment", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("active", models.BooleanField(default=True)), + ("deleted", models.BooleanField(default=False)), + ("created_on", models.DateTimeField(auto_now_add=True)), + ("modified_on", models.DateTimeField(auto_now=True)), + ("image", models.ImageField(upload_to="ticket_attachment")), + ( + "created_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="%(class)s_created", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "modified_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="%(class)s_modified", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "db_table": "ticket_attachment", + }, + ), + migrations.CreateModel( + name="TicketIssueType", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("active", models.BooleanField(default=True)), + ("deleted", models.BooleanField(default=False)), + ("created_on", models.DateTimeField(auto_now_add=True)), + ("modified_on", models.DateTimeField(auto_now=True)), + ("name", models.CharField(max_length=100)), + ( + "created_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="%(class)s_created", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "modified_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="%(class)s_modified", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "db_table": "ticket_issue_type", + }, + ), + migrations.CreateModel( + name="Tickets", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("active", models.BooleanField(default=True)), + ("deleted", models.BooleanField(default=False)), + ("created_on", models.DateTimeField(auto_now_add=True)), + ("modified_on", models.DateTimeField(auto_now=True)), + ("subject", models.CharField(max_length=100)), + ("description", models.TextField()), + ( + "ticket_status", + models.CharField( + choices=[ + ("Requested", "Requested"), + ("Viewed", "Viewed"), + ("In Progress", "In Progress"), + ("Resolved", "Resolved"), + ], + default="Requested", + help_text="Ticket status", + max_length=20, + ), + ), + ("is_stopped", models.BooleanField(default=True)), + ( + "attachments", + models.ManyToManyField( + related_name="tickets_attachments", + to="manage_communications.ticketattachment", + ), + ), + ( + "created_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="%(class)s_created", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "issuetype", + models.ForeignKey( + on_delete=django.db.models.deletion.DO_NOTHING, + to="manage_communications.ticketissuetype", + ), + ), + ( + "modified_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="%(class)s_modified", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "principal", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "db_table": "tickets", + }, + ), + ] diff --git a/manage_communications/migrations/__init__.py b/manage_communications/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/manage_communications/models.py b/manage_communications/models.py new file mode 100644 index 0000000..73833be --- /dev/null +++ b/manage_communications/models.py @@ -0,0 +1,127 @@ +from django.db import models +from accounts.models import BaseModel, IAmPrincipal, IAmPrincipalType + + +class ContactUs(BaseModel): + name = models.CharField(max_length=100, null=True) + email_address = models.EmailField() + mobile_number = models.CharField(max_length=15) + subject = models.CharField(max_length=200) + message = models.TextField() + reply = models.TextField(blank=True, null=True) + + class Meta: + db_table = "contact_us" + +class TicketIssueType(BaseModel): + name = models.CharField(max_length=100) + + class Meta: + db_table = "ticket_issue_type" + +class TicketAttachment(BaseModel): + image = models.ImageField(upload_to="ticket_attachment") + + class Meta: + db_table = "ticket_attachment" + +class Tickets(BaseModel): + REQUESTED = "Requested" + VIEWED = "Viewed" + IN_PROGRESS = "In Progress" + RESOLVED = "Resolved" + + TICKET_STATUS = ( + (REQUESTED, "Requested"), + (VIEWED, "Viewed"), + (IN_PROGRESS, "In Progress"), + (RESOLVED, "Resolved"), + ) + issuetype = models.ForeignKey(TicketIssueType, on_delete=models.DO_NOTHING) + principal = models.ForeignKey(IAmPrincipal, on_delete=models.DO_NOTHING, null=True, blank=True) + subject = models.CharField(max_length=100) + description = models.TextField() + ticket_status = models.CharField( + max_length=20, + choices=TICKET_STATUS, + default=REQUESTED, + help_text="Ticket status", + ) + attachments = models.ManyToManyField(TicketAttachment, related_name="tickets_attachments") + is_stopped = models.BooleanField(default=True) + + class Meta: + db_table = "tickets" + + def __str__(self): + return f"{self.subject}" + + def mark_as_viewed(self): + self.ticket_status = self.VIEWED + self.save() + + def mark_as_in_progress(self): + self.ticket_status = self.IN_PROGRESS + self.save() + + def mark_as_resolved(self): + self.ticket_status = self.RESOLVED + self.save() + + def stop_ticket(self): + self.is_stopped = True + self.ticket_status = self.RESOLVED + self.save() + +class Feedback(BaseModel): + VERY_BAD = "Very Bad" + POOR = "Poor" + MEDIUM = "Medium" + GOOD = "Good" + EXCELLENT = "Excellent" + + RATING_CHOICES = ( + (1, "1 Star"), + (2, "2 Stars"), + (3, "3 Stars"), + (4, "4 Stars"), + (5, "5 Stars"), + ) + FEEDBACK_REACTION = ( + (VERY_BAD, "Very Bad"), + (POOR, "Poor"), + (MEDIUM, "Medium"), + (GOOD, "Good"), + (EXCELLENT, "Excellent"), + ) + + principal = models.ForeignKey( + IAmPrincipal, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="feedbacks", + help_text="User associated with this feedback", + ) + email = models.EmailField(null=True, blank=True, help_text="Email address of the feedback provider") + comment = models.TextField(help_text="Feedback comment") + rating = models.PositiveSmallIntegerField(choices=RATING_CHOICES, help_text="Rating provided by the user") + feedback_reaction = models.CharField( + max_length=20, + choices=FEEDBACK_REACTION, + null=True, + blank=True, + help_text="Reaction associated with the feedback", + ) + + class Meta: + db_table = "feedback" + + def __str__(self): + return f"Author: {self.principal}, Comment: {self.comment}, Rating: {self.rating}" + + def get_rating_display(self): + for value, label in self.RATING_CHOICES: + if value == self.rating: + return label + return "" diff --git a/manage_communications/tests.py b/manage_communications/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/manage_communications/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/manage_communications/urls.py b/manage_communications/urls.py new file mode 100644 index 0000000..92eaa3d --- /dev/null +++ b/manage_communications/urls.py @@ -0,0 +1,15 @@ +from django.urls import path +from . import views + +app_name = "manage_communications" + +urlpatterns = [ + path('contact_us/', views.ContactUsListView.as_view(), name='contact_us_list'), + path('contact_us/reply/', views.ContactUsReplyView.as_view(), name='contact_us_reply'), + + path('tickets/', views.TicketListView.as_view(), name='ticket_list'), + + path('feedback/', views.FeedbackListView.as_view(), name='feedback_list'), + path('feedback/delete/', views.FeedbackDeleteView.as_view(), name='feedback_delete'), + +] diff --git a/manage_communications/views.py b/manage_communications/views.py new file mode 100644 index 0000000..e4a5bcc --- /dev/null +++ b/manage_communications/views.py @@ -0,0 +1,132 @@ +from django.contrib.auth.mixins import LoginRequiredMixin +from accounts import resource_action +from django.views import generic +from .models import Feedback, ContactUs, Tickets +from goodtimes import constants +from django.shortcuts import get_object_or_404, redirect, render +from django.urls import reverse_lazy +from django.contrib import messages + +# Create your views here. + + +class ContactUsListView(LoginRequiredMixin, generic.ListView): + page_name = resource_action.RESOURCE_MANAGE_CONTACT_US + resource = resource_action.RESOURCE_MANAGE_CONTACT_US + action = resource_action.ACTION_READ + model = ContactUs + template_name = "manage_communications/contact_us_list.html" + context_object_name = "contact_us_obj" + + def get_queryset(self): + return super().get_queryset().filter(deleted=False) + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["page_name"] = self.page_name + return context + +class ContactUsReplyView(LoginRequiredMixin, generic.View): + page_name = resource_action.RESOURCE_MANAGE_CONTACT_US + model = ContactUs + success_message = constants.DATA_SAVED + success_url = reverse_lazy("manage_communications:contact_us_list") + + def post(self, request): + id = request.POST.get("id") + message = request.POST.get("message") + + if id or message: + try: + instance = self.model.objects.get(id=id) + instance.reply = message + instance.save() + messages.success(request, self.success_message) + except self.model.DoesNotExist: + messages.error(request, "Contact Us entry not found") + except Exception as e: + messages.error(request, str(e)) + else: + messages.error(request, "Missing 'id' or 'message' in the request") + + # Redirect to the desired URL after form submission + return redirect(self.success_url) + + +class ContactUsCreateOrUpdateView(LoginRequiredMixin, generic.TemplateView): + page_name = resource_action.RESOURCE_MANAGE_CONTACT_US + template_name = "manage_communications/contact_us_list.html" + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["page_name"] = self.page_name + return context + +class TicketsListView(LoginRequiredMixin, generic.ListView): + page_name = resource_action.RESOURCE_MANAGE_TICKET + resource = resource_action.RESOURCE_MANAGE_TICKET + action = resource_action.ACTION_READ + model = ContactUs + template_name = "manage_communications/ticket_list.html" + context_object_name = "objs" + + def get_queryset(self): + return super().get_queryset().filter(deleted=False) + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["page_name"] = self.page_name + return context + +class TicketListView(LoginRequiredMixin, generic.ListView): + page_name = resource_action.RESOURCE_MANAGE_CONTACT_US + resource = resource_action.RESOURCE_MANAGE_CONTACT_US + action = resource_action.ACTION_READ + model = ContactUs + template_name = "manage_communications/contact_us_list.html" + context_object_name = "contact_us_obj" + + def get_queryset(self): + return super().get_queryset().filter(deleted=False) + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["page_name"] = self.page_name + return context + +class FeedbackListView(LoginRequiredMixin, generic.ListView): + page_name = resource_action.RESOURCE_MANAGE_FEEDBACK + resource = resource_action.RESOURCE_MANAGE_FEEDBACK + action = resource_action.ACTION_READ + model = Feedback + template_name = "manage_communications/feedback_list.html" + context_object_name = "feedback_obj" + + def get_queryset(self): + return super().get_queryset().select_related("principal").filter(deleted=False) + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["page_name"] = self.page_name + return context + + +class FeedbackDeleteView(LoginRequiredMixin, generic.View): + page_name = resource_action.RESOURCE_MANAGE_FEEDBACK + resource = resource_action.RESOURCE_MANAGE_FEEDBACK + action = resource_action.ACTION_DELETE + model = Feedback + success_url = reverse_lazy("manage_communications:feedback_list") + success_message = constants.RECORD_DELETED + error_message = constants.RECORD_NOT_FOUND + + def get(self, request, pk): + try: + type_obj = self.model.objects.get(id=pk) + type_obj.deleted = True + type_obj.save() + messages.success(request, self.success_message) + except self.model.DoesNotExist: + messages.success(request, self.error_message) + + return redirect(self.success_url) diff --git a/manage_events/__init__.py b/manage_events/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/manage_events/admin.py b/manage_events/admin.py new file mode 100644 index 0000000..0c0464b --- /dev/null +++ b/manage_events/admin.py @@ -0,0 +1,24 @@ +from django.contrib import admin +from .models import EventCategory, Venue, EventMaster +# Register your models here. +class EventCategoryAdmin(admin.ModelAdmin): + list_display = ('title', 'description') + search_fields = ('title', 'description') + list_filter = ('title',) + + +class VenueAdmin(admin.ModelAdmin): + list_display = ('title', 'address', 'latitude', 'longitude') + search_fields = ('title', 'address') + list_filter = ('title',) + + +class EventMasterAdmin(admin.ModelAdmin): + list_display = ('title', 'event_category', 'description') + search_fields = ('title', 'description') + list_filter = ('event_category', 'title') + + +admin.site.register(EventCategory, EventCategoryAdmin) +admin.site.register(Venue, VenueAdmin) +admin.site.register(EventMaster, EventMasterAdmin) diff --git a/manage_events/api/serializers.py b/manage_events/api/serializers.py new file mode 100644 index 0000000..08ef939 --- /dev/null +++ b/manage_events/api/serializers.py @@ -0,0 +1,165 @@ +from rest_framework import serializers +from accounts.models import IAmPrincipalLocation +from manage_events.models import ( + EventMaster, + Event, + EventCategory, + EventImage, + Venue, + PrincipalPreference, +) + + +class EventImageSerializer(serializers.ModelSerializer): + class Meta: + model = EventImage + fields = ["image"] + + +class EventDetailSerializer(serializers.ModelSerializer): + images = serializers.SerializerMethodField() + + class Meta: + model = Event + fields = [ + "id", + "title", + "description", + "start_date", + "end_date", + "from_time", + "to_time", + "category", + "venue", + "venue_capacity", + "image", + "video_url", + "entry_type", + "entry_fee", + "key_guest", + "age_group", + "images", + ] + + def get_images(self, obj): + images = ( + obj.event_images.all() + ) # Ensure this uses the correct related_name from your model + return EventImageSerializer(images, many=True, context=self.context).data + + +class CreateEventSerializer(serializers.ModelSerializer): + images = serializers.ListField( + child=serializers.ImageField(), write_only=True, required=False + ) + + class Meta: + model = Event + fields = [ + "id", + "images", + "title", + "description", + "image", + "start_date", + "end_date", + "from_time", + "to_time", + "category", + "venue_capacity", + "video_url", + "entry_type", + "entry_fee", + "key_guest", + "age_group", + "draft", + "venue", + ] + + def create(self, validated_data): + images_data = validated_data.pop("images", None) + event = Event.objects.create(**validated_data) + + if images_data: + for image_data in images_data: + EventImage.objects.create(event=event, image=image_data) + + return event + + +class CreateVenueSerializer(serializers.ModelSerializer): + class Meta: + model = Venue + fields = [ + "title", + "description", + "address", + "image", + "url", + "latitude", + "longitude", + ] + + +class EventCategorySerializer(serializers.ModelSerializer): + class Meta: + model = EventCategory + fields = ["id", "title", "image", "description", "video_url"] + + +class IAmPrincipalLocationSerializer(serializers.ModelSerializer): + class Meta: + model = IAmPrincipalLocation + fields = ["latitude", "longitude"] + + def create(self, validated_data): + principal = self.context["request"].user + return IAmPrincipalLocation.objects.create( + principal=principal, **validated_data + ) + + +class VenueSerializer(serializers.ModelSerializer): + class Meta: + model = Venue + fields = "__all__" + read_only_fields = ("created_by",) + + +class PrincipalPreferenceSerializer(serializers.ModelSerializer): + preferred_categories = serializers.PrimaryKeyRelatedField( + many=True, queryset=EventCategory.objects.all() + ) + + class Meta: + model = PrincipalPreference + fields = ["preferred_categories"] # Removed 'principal' from fields + + def create(self, validated_data): + # Accessing the principal from the context + user = self.context["request"].user + preferred_categories = validated_data.pop("preferred_categories") + principal_preference, created = PrincipalPreference.objects.update_or_create( + principal=user, defaults={} + ) + principal_preference.preferred_categories.set(preferred_categories) + return principal_preference + + def update(self, instance, validated_data): + instance.preferred_categories.set( + validated_data.get( + "preferred_categories", instance.preferred_categories.all() + ) + ) + instance.save() + return instance + + +class EventMasterSearchSerializer(serializers.Serializer): + title = serializers.CharField(max_length=255) + + +class EventMasterSerializer(serializers.ModelSerializer): + class Meta: + model = EventMaster + fields = "__all__" diff --git a/manage_events/api/urls.py b/manage_events/api/urls.py new file mode 100644 index 0000000..3975903 --- /dev/null +++ b/manage_events/api/urls.py @@ -0,0 +1,59 @@ +from django.urls import path +from . import views + +app_name = "manage_events_api" + +urlpatterns = [ + path( + "add-event/", + views.CreateEventApi.as_view(), + name="add_event", + ), + path( + "get-events//", + views.EventsAPIView.as_view(), + name="get_events", + ), + path( + "event//", + views.EventDetailAPIView.as_view(), + name="get_event", + ), + path( + "add-venue/", + views.CreateVenueApi.as_view(), + name="add_venue", + ), + path( + "get-venue/", + views.VenueListView.as_view(), + name="get_venue", + ), + path("event-master/search/", views.EventMasterSearchAPIView.as_view(), name="event_master_search"), + # Others + path("geocode/", views.GeocodeAPIView.as_view(), name="geocode_api"), + # All Preferences List + path( + "event-categories/", + views.EventCategoryListAPIView.as_view(), + name="event-category-list", + ), + # Add Principal Preferences + path( + "add-principal-preferences/", + views.PrincipalPreferenceView.as_view(), + name="principal_preferences", + ), + # Principal Preference List + path( + "principal-preferences/", + views.PrincipalPreferenceDetailView.as_view(), + name="principal-preferences", + ), + # Principal Location + path( + "add-location/", + views.IAmPrincipalLocationAPIView.as_view(), + name="add_location", + ), +] diff --git a/manage_events/api/views.py b/manage_events/api/views.py new file mode 100644 index 0000000..8a7a2f0 --- /dev/null +++ b/manage_events/api/views.py @@ -0,0 +1,394 @@ +from datetime import timedelta +from django.db import transaction +from django.utils import timezone +from rest_framework import status, generics +from rest_framework.views import APIView +from django.conf import settings +from accounts.models import IAmPrincipalLocation +from accounts.permission import IsOwnerOrReadOnly +from goodtimes import constants +from django.db.models import Q +from goodtimes import services +from goodtimes.utils import ApiResponse, CapacityError +from rest_framework.permissions import IsAuthenticated +from rest_framework_simplejwt.authentication import JWTAuthentication +from manage_events.api.serializers import ( + EventMasterSearchSerializer, + EventMasterSerializer, + CreateEventSerializer, + CreateVenueSerializer, + EventCategorySerializer, + EventDetailSerializer, + IAmPrincipalLocationSerializer, + PrincipalPreferenceSerializer, + VenueSerializer, +) +from manage_events.models import EventMaster, Event, EventCategory, PrincipalPreference, Venue +import requests + + +class CreateEventApi(APIView): + authentication_classes = [JWTAuthentication] + permission_classes = [IsAuthenticated] + + def post(self, request): + serializer = CreateEventSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + serializer.save(created_by=self.request.user) + + # Add additional logic for handling other relationships (e.g., Venue) + return ApiResponse.success( + status=status.HTTP_201_CREATED, + message=constants.SUCCESS, + data=serializer.data, + ) + + +class CreateVenueApi(APIView): + authentication_classes = [JWTAuthentication] + permission_classes = [IsAuthenticated] + + def post(self, request): + serializer = VenueSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + serializer.save(created_by=self.request.user, active=True) + + # Add additional logic for handling other relationships (e.g., Venue) + return ApiResponse.success( + status=status.HTTP_201_CREATED, + message=constants.SUCCESS, + data=serializer.data, + ) + + +class EventsAPIView(APIView): + authentication_classes = [JWTAuthentication] + permission_classes = [IsAuthenticated] + + def get(self, request, filter, *args, **kwargs): + today = timezone.now().date() + params = ["expensive", "cheap", "preference", "today", "tomorrow"] + if filter not in params: + return ApiResponse.error( + status=status.HTTP_400_BAD_REQUEST, + message=constants.FAILURE, + errors="No filter found", + ) + + try: + + if filter == "expensive": + + # Constructing a complex query using Q objects + current_and_future_events_query = Q(active=True, deleted=False) & ( + Q(start_date__lte=today, end_date__gte=today) + | Q(start_date__gt=today) # Current events # Future events + ) + + current_and_future_events = Event.objects.filter( + current_and_future_events_query + ).order_by("-entry_fee") + serializer = EventDetailSerializer(current_and_future_events, many=True) + return ApiResponse.success( + status=status.HTTP_200_OK, + message=constants.SUCCESS, + data=serializer.data, + ) + elif filter == "cheap": + current_and_future_events_query = Q(active=True, deleted=False) & ( + Q(start_date__lte=today, end_date__gte=today) + | Q(start_date__gt=today) # Current events # Future events + ) + + current_and_future_events = Event.objects.filter( + current_and_future_events_query + ).order_by("entry_fee") + serializer = EventDetailSerializer(current_and_future_events, many=True) + return ApiResponse.success( + status=status.HTTP_200_OK, + message=constants.SUCCESS, + data=serializer.data, + ) + elif filter == "preference": + preferences = PrincipalPreference.objects.get(principal=request.user) + preferred_categories_ids = preferences.preferred_categories.values_list( + "id", flat=True + ) + # Filter events based on user preferences and that are upcoming or ongoing + preference_events = Event.objects.filter( + category__in=preferred_categories_ids, end_date__gte=today + ).distinct() + serializer = EventDetailSerializer(preference_events, many=True) + return ApiResponse.success( + status=status.HTTP_200_OK, + message=constants.SUCCESS, + data=serializer.data, + ) + + except Exception as e: + return ApiResponse.error( + status=status.HTTP_400_BAD_REQUEST, + message=constants.FAILURE, + errors=str(e), + ) + + +class PrinciaplPreferenceEventsAPIView(APIView): + authentication_classes = [JWTAuthentication] + permission_classes = [IsAuthenticated] + + def get(self, request, *args, **kwargs): + today = timezone.now().date() + try: + preferences = PrincipalPreference.objects.get(principal=request.user) + preferred_categories_ids = preferences.preferred_categories.values_list( + "id", flat=True + ) + # Filter events based on user preferences and that are upcoming or ongoing + events = Event.objects.filter( + category__in=preferred_categories_ids, end_date__gte=today + ).distinct() + + if not events.exists(): + # If no events found based on preferences, get future events + events = Event.objects.filter(start_date__gt=today) + serializer = EventDetailSerializer(events, many=True) + return ApiResponse.success( + status=status.HTTP_200_OK, + message=constants.SUCCESS, + data=serializer.data, + ) + + except PrincipalPreference.DoesNotExist: + # If the user has no preferences, default to future events + events = Event.objects.filter(start_date__gt=today) + + +class EventDetailAPIView(APIView): + authentication_classes = [JWTAuthentication] + permission_classes = [IsAuthenticated] + + def get(self, request, pk): + try: + event = Event.objects.get(pk=pk) + interaction = services.InteractionCalculator(event) + interactions = interaction.calculate() + + # Serialize your event data here + event_data = EventDetailSerializer(event).data + event_data["interactions"] = interactions + + return ApiResponse.success( + status=status.HTTP_200_OK, + message=constants.SUCCESS, + data=event_data, + ) + except Event.DoesNotExist: + return ApiResponse.error( + status=status.HTTP_404_NOT_FOUND, + message=constants.FAILURE, + errors=constants.RECORD_NOT_FOUND, + ) + except CapacityError as e: + return ApiResponse.error( + status=status.HTTP_405_METHOD_NOT_ALLOWED, + message=constants.FAILURE, + errors=str(e), + ) + + +class VenueListView(generics.ListAPIView): + serializer_class = VenueSerializer + permission_classes = [IsAuthenticated] + + def get_queryset(self): + # Ensures that a user sees only their venues + return Venue.objects.filter(created_by=self.request.user) + + def list(self, request, *args, **kwargs): + queryset = self.get_queryset() + serializer = self.get_serializer(queryset, many=True) + # Customizing the response format + return ApiResponse.success( + status=status.HTTP_200_OK, + message=constants.SUCCESS, + data=serializer.data, + ) + + +class VenueDetailView(generics.RetrieveUpdateDestroyAPIView): + queryset = Venue.objects.all() + serializer_class = VenueSerializer + permission_classes = [IsAuthenticated, IsOwnerOrReadOnly] + + def get_queryset(self): + # This ensures a user can only access their own venues + return self.queryset.filter(created_by=self.request.user) + + +class GeocodeAPIView(APIView): + authentication_classes = [] + permission_classes = [] + """ + API View to fetch latitude and longitude for a given address using the Google Maps Geocoding API. + """ + + def get(self, request, *args, **kwargs): + # Extract 'address' from query parameters + address = request.query_params.get("address") + print("address: ", address) + if not address: + return ApiResponse.error( + {"error": "Address parameter is missing."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Call the get_lat_long method + lat_long = self.get_lat_long(address) + if lat_long: + return ApiResponse.success( + {"latitude": lat_long[0], "longitude": lat_long[1]} + ) + else: + return ApiResponse.error( + {"error": "Failed to fetch latitude and longitude."}, + status=status.HTTP_404_NOT_FOUND, + ) + + def get_lat_long(self, address): + """ + Fetches latitude and longitude for a given address using the Google Maps Geocoding API. + """ + url = "https://maps.googleapis.com/maps/api/geocode/json?" + params = { + "address": address, + # "key": settings.GOOGLE_MAPS_API_KEY, # Replace with your actual API key + "key": "AIzaSyCQv-Cfzkh3cXerrui55oId7CDHhuIImhc", # Replace with your actual API key + } + + response = requests.get(url, params=params) + + if response.status_code == 200: + data = response.json() + if data["status"] == "OK": + location = data["results"][0]["geometry"]["location"] + latitude = location["lat"] + longitude = location["lng"] + return latitude, longitude + else: + print(f"Geocoding failed: {data['status']}") + return None + else: + print(f"API request failed with status code: {response.status_code}") + return None + + +class EventCategoryListAPIView(generics.ListAPIView): + authentication_classes = [JWTAuthentication] + permission_classes = [IsAuthenticated] + queryset = EventCategory.objects.all() + serializer_class = EventCategorySerializer + + +class IAmPrincipalLocationAPIView(APIView): + authentication_classes = [JWTAuthentication] + permission_classes = [IsAuthenticated] + + def get(self, request, *args, **kwargs): + # Assuming there's a UserLocation model that stores user locations, + # and it has a ForeignKey to the user model. + try: + user_location = IAmPrincipalLocation.objects.filter(principal=request.user).last() + serializer = IAmPrincipalLocationSerializer(user_location) + return ApiResponse.success( + status=status.HTTP_200_OK, + message=constants.SUCCESS, + data=serializer.data, + ) + except IAmPrincipalLocation.DoesNotExist: + return ApiResponse.error( + status=status.HTTP_404_NOT_FOUND, + message="Location not found for the user.", + errors="No location data available for the current user.", + ) + + def post(self, request, *args, **kwargs): + serializer = IAmPrincipalLocationSerializer( + data=request.data, context={"request": request} + ) + if serializer.is_valid(): + serializer.save() + return ApiResponse.success( + status=status.HTTP_200_OK, + message=constants.SUCCESS, + data=serializer.data, + ) + return ApiResponse.error( + status=status.HTTP_400_BAD_REQUEST, + message=constants.FAILURE, + errors=serializer.errors, + ) + + +class PrincipalPreferenceView(APIView): + authentication_classes = [JWTAuthentication] + permission_classes = [IsAuthenticated] + + def post(self, request, *args, **kwargs): + serializer = PrincipalPreferenceSerializer( + data=request.data, context={"request": request} + ) + if serializer.is_valid(): + serializer.save() + return ApiResponse.success( + message=constants.SUCCESS, + data=serializer.data, + status=status.HTTP_201_CREATED, + ) + return ApiResponse.error( + message=constants.FAILURE, + errors=serializer.errors, + status=status.HTTP_400_BAD_REQUEST, + ) + + +class PrincipalPreferenceDetailView(generics.RetrieveAPIView): + authentication_classes = [JWTAuthentication] + permission_classes = [IsAuthenticated] + serializer_class = PrincipalPreferenceSerializer + + def get_object(self): + # Attempt to retrieve the authenticated user's preferences + user = self.request.user + obj, created = PrincipalPreference.objects.get_or_create(principal=user) + return obj + + +class EventMasterSearchAPIView(APIView): + def post(self, request, *args, **kwargs): + serializer = EventMasterSearchSerializer(data=request.data) + if serializer.is_valid(): + title = serializer.validated_data.get("title") + # Search for existing brands + existing_event_master = EventMaster.objects.filter(title__icontains=title) + if existing_event_master.exists(): + # Return existing brands + return ApiResponse.success( + data=EventMasterSerializer(existing_event_master, many=True).data, + message=constants.SUCCESS, + status=status.HTTP_200_OK, + ) + else: + # No brand found, return an empty list + return ApiResponse.success( + data=[], + message="No related EventMaster Found", + status=status.HTTP_200_OK, + ) + return ApiResponse.error( + message=constants.FAILURE, + errors=serializer.errors, + status=status.HTTP_400_BAD_REQUEST, + ) diff --git a/manage_events/apps.py b/manage_events/apps.py new file mode 100644 index 0000000..d52ffb3 --- /dev/null +++ b/manage_events/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class ManageEventsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "manage_events" diff --git a/manage_events/forms.py b/manage_events/forms.py new file mode 100644 index 0000000..9839e4c --- /dev/null +++ b/manage_events/forms.py @@ -0,0 +1,72 @@ +from django import forms +from manage_events.models import EventMaster, Event, EventCategory + + +class EventCategoryForm(forms.ModelForm): + # image = forms.ImageField() + + class Meta: + model = EventCategory + fields = ["title", "image", "description", "video_url"] + widgets = { + "image": forms.FileInput(attrs={"class": "form-control-file"}), + } + + +class EventForm(forms.ModelForm): + class Meta: + model = Event + fields = [ + "title", + "event_master", + "description", + "image", + "status", + "start_date", + "end_date", + "from_time", + "to_time", + "venue", + "venue_capacity", + "video_url", + "entry_type", + "entry_fee", + "key_guest", + "age_group", + "draft", + ] + widgets = { + "title": forms.TextInput(attrs={"class": "form-control"}), + "description": forms.Textarea(attrs={"class": "form-control", "rows": 4}), + "status": forms.Select(attrs={"class": "form-control"}), + "start_date": forms.DateInput( + attrs={"class": "form-control", "type": "date"} + ), + "end_date": forms.DateInput( + attrs={"class": "form-control", "type": "date"} + ), + "from_time": forms.TimeInput( + attrs={"class": "form-control", "type": "time"} + ), + "to_time": forms.TimeInput(attrs={"class": "form-control", "type": "time"}), + "venue_capacity": forms.NumberInput(attrs={"class": "form-control"}), + "video_url": forms.URLInput(attrs={"class": "form-control"}), + "entry_type": forms.TextInput(attrs={"class": "form-control"}), + "entry_fee": forms.NumberInput(attrs={"class": "form-control"}), + "key_guest": forms.Textarea(attrs={"class": "form-control", "rows": 3}), + "age_group": forms.TextInput(attrs={"class": "form-control"}), + "draft": forms.CheckboxInput(attrs={"class": "form-check-input"}), + # For the 'image' field, you might not need to specify a widget since the default is appropriate. + # However, if you want to add specific classes or attributes, you can do it like this: + "image": forms.FileInput(attrs={"class": "form-control-file"}), + # For ForeignKey fields like 'EventMaster' and 'venue', Django uses a select widget by default. + # You can customize it further if needed: + "event_master": forms.Select(attrs={"class": "form-control"}), + "venue": forms.Select(attrs={"class": "form-control"}), + } + + +class EventMasterForm(forms.ModelForm): + class Meta: + model = EventMaster + fields = "__all__" # Includes all fields from the model diff --git a/manage_events/migrations/0001_initial.py b/manage_events/migrations/0001_initial.py new file mode 100644 index 0000000..d79d243 --- /dev/null +++ b/manage_events/migrations/0001_initial.py @@ -0,0 +1,378 @@ +# Generated by Django 5.0.2 on 2024-02-29 07:47 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="EventCategory", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("active", models.BooleanField(default=True)), + ("deleted", models.BooleanField(default=False)), + ("created_on", models.DateTimeField(auto_now_add=True)), + ("modified_on", models.DateTimeField(auto_now=True)), + ("title", models.CharField(max_length=255)), + ( + "image", + models.ImageField( + blank=True, null=True, upload_to="event_category" + ), + ), + ("description", models.TextField(blank=True, null=True)), + ("video_url", models.URLField(blank=True, null=True)), + ( + "created_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="%(class)s_created", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "modified_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="%(class)s_modified", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "abstract": False, + }, + ), + migrations.CreateModel( + name="Event", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("active", models.BooleanField(default=True)), + ("deleted", models.BooleanField(default=False)), + ("created_on", models.DateTimeField(auto_now_add=True)), + ("modified_on", models.DateTimeField(auto_now=True)), + ("title", models.CharField(max_length=255)), + ("description", models.TextField(blank=True, null=True)), + ("image", models.ImageField(blank=True, null=True, upload_to="event")), + ( + "status", + models.CharField( + blank=True, + choices=[ + ("upcoming", "Upcoming"), + ("live", "Live"), + ("post", "Post"), + ("archive", "Archive"), + ], + max_length=10, + null=True, + ), + ), + ("start_date", models.DateField()), + ("end_date", models.DateField()), + ("from_time", models.TimeField()), + ("to_time", models.TimeField()), + ("venue_capacity", models.IntegerField()), + ("video_url", models.URLField(blank=True, null=True)), + ("entry_type", models.CharField(max_length=100)), + ( + "entry_fee", + models.DecimalField(decimal_places=2, default=0.0, max_digits=14), + ), + ("key_guest", models.TextField(blank=True, null=True)), + ("age_group", models.CharField(blank=True, max_length=100, null=True)), + ("draft", models.BooleanField(default=False)), + ( + "created_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="%(class)s_created", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "modified_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="%(class)s_modified", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "category", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="manage_events.eventcategory", + ), + ), + ], + options={ + "db_table": "event", + }, + ), + migrations.CreateModel( + name="EventImage", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("image", models.ImageField(upload_to="event_images/")), + ( + "event", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="event_images", + to="manage_events.event", + ), + ), + ], + ), + migrations.CreateModel( + name="EventMaster", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("active", models.BooleanField(default=True)), + ("deleted", models.BooleanField(default=False)), + ("created_on", models.DateTimeField(auto_now_add=True)), + ("modified_on", models.DateTimeField(auto_now=True)), + ("title", models.CharField(max_length=255)), + ("description", models.TextField(blank=True, null=True)), + ("image", models.ImageField(blank=True, null=True, upload_to="brand")), + ( + "created_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="%(class)s_created", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "event_category", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="manage_events.eventcategory", + ), + ), + ( + "modified_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="%(class)s_modified", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "db_table": "brand", + }, + ), + migrations.AddField( + model_name="event", + name="event_master", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="manage_events.eventmaster", + ), + ), + migrations.CreateModel( + name="PrincipalPreference", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("active", models.BooleanField(default=True)), + ("deleted", models.BooleanField(default=False)), + ("created_on", models.DateTimeField(auto_now_add=True)), + ("modified_on", models.DateTimeField(auto_now=True)), + ( + "created_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="%(class)s_created", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "modified_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="%(class)s_modified", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "preferred_categories", + models.ManyToManyField( + related_name="preferred_by_users", + to="manage_events.eventcategory", + ), + ), + ( + "principal", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "db_table": "user_preference", + }, + ), + migrations.CreateModel( + name="Venue", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("active", models.BooleanField(default=True)), + ("deleted", models.BooleanField(default=False)), + ("created_on", models.DateTimeField(auto_now_add=True)), + ("modified_on", models.DateTimeField(auto_now=True)), + ("title", models.CharField(max_length=255)), + ("description", models.TextField(blank=True, null=True)), + ("address", models.TextField(blank=True, null=True)), + ("image", models.ImageField(blank=True, null=True, upload_to="venue")), + ("url", models.URLField(blank=True, null=True)), + ("latitude", models.DecimalField(decimal_places=8, max_digits=14)), + ("longitude", models.DecimalField(decimal_places=8, max_digits=14)), + ( + "created_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="%(class)s_created", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "modified_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="%(class)s_modified", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "abstract": False, + }, + ), + migrations.AddField( + model_name="event", + name="venue", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to="manage_events.venue" + ), + ), + migrations.CreateModel( + name="EventPrincipalInteraction", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "status", + models.CharField( + choices=[("going", "Going"), ("interested", "Interested")], + max_length=10, + ), + ), + ( + "event", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="manage_events.event", + ), + ), + ( + "principal", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "unique_together": {("principal", "event", "status")}, + }, + ), + ] diff --git a/manage_events/migrations/__init__.py b/manage_events/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/manage_events/models.py b/manage_events/models.py new file mode 100644 index 0000000..e510756 --- /dev/null +++ b/manage_events/models.py @@ -0,0 +1,122 @@ +from django.db import models +from accounts.models import BaseModel, IAmPrincipal +from django.db import transaction +# from django.contrib.gis.db import models as gis_models + +# Create your models here. +class EventCategory(BaseModel): + title = models.CharField(max_length=255) + image = models.ImageField(upload_to="event_category", null=True, blank=True) + description = models.TextField(null=True, blank=True) + video_url = models.URLField(max_length=200, blank=True, null=True) + + def __str__(self): + return self.title + + +class Venue(BaseModel): + title = models.CharField(max_length=255) + description = models.TextField(null=True, blank=True) + address = models.TextField(null=True, blank=True) + image = models.ImageField(upload_to="venue", null=True, blank=True) + url = models.URLField(max_length=200, blank=True, null=True) + latitude = models.DecimalField(max_digits=14, decimal_places=8) + longitude = models.DecimalField(max_digits=14, decimal_places=8) + + def __str__(self): + return self.title + + +class EventStatus(models.TextChoices): + UPCOMING = "upcoming", "Upcoming" + LIVE = "live", "Live" + POST = "post", "Post" + ARCHIVE = "archive", "Archive" + + +class EventMaster(BaseModel): + title = models.CharField(max_length=255) + description = models.TextField(blank=True, null=True) + event_category = models.ForeignKey(EventCategory, on_delete=models.CASCADE) + image = models.ImageField(upload_to="brand", null=True, blank=True) + + def __str__(self): + return self.title + + class Meta: + db_table = "brand" + + +class Event(BaseModel): + title = models.CharField(max_length=255) + category = models.ForeignKey(EventCategory, on_delete=models.CASCADE) + event_master = models.ForeignKey(EventMaster, on_delete=models.SET_NULL, null=True, blank=True) + description = models.TextField(blank=True, null=True) + image = models.ImageField(upload_to="event", null=True, blank=True) + status = models.CharField( + max_length=10, choices=EventStatus.choices, blank=True, null=True + ) + start_date = models.DateField() + end_date = models.DateField() + from_time = models.TimeField() + to_time = models.TimeField() + + venue = models.ForeignKey(Venue, on_delete=models.CASCADE) + venue_capacity = models.IntegerField() + + video_url = models.URLField(max_length=200, blank=True, null=True) + entry_type = models.CharField( + max_length=100 + ) # Assuming entry type is a string (e.g., Free, Ticketed) + entry_fee = models.DecimalField( + max_digits=14, decimal_places=2, default=0.00 + ) # Assuming it's an integer. Use DecimalField if you need to handle cents. + key_guest = models.TextField(blank=True, null=True) + age_group = models.CharField(max_length=100, blank=True, null=True) + draft = models.BooleanField(default=False) + + def __str__(self): + return self.title + + class Meta: + db_table = "event" # Optional: Specify custom table name + + +class EventInteractionType(models.TextChoices): + GOING = "going", "Going" + INTERESTED = "interested", "Interested" + + +class EventImage(models.Model): + event = models.ForeignKey( + Event, related_name="event_images", on_delete=models.CASCADE + ) + image = models.ImageField(upload_to="event_images/") + + def __str__(self): + return f"Image for event: {self.event.title}" + + +class EventPrincipalInteraction(models.Model): + principal = models.ForeignKey(IAmPrincipal, on_delete=models.CASCADE) + event = models.ForeignKey(Event, on_delete=models.CASCADE) + status = models.CharField( + max_length=10, + choices=EventInteractionType.choices, + ) + + class Meta: + unique_together = ("principal", "event", "status") + + +class PrincipalPreference(BaseModel): + principal = models.OneToOneField(IAmPrincipal, on_delete=models.CASCADE) + preferred_categories = models.ManyToManyField( + EventCategory, related_name="preferred_by_users" + ) + + def __str__(self): + return str(self.preferred_categories) + + class Meta: + db_table = "user_preference" diff --git a/manage_events/tests.py b/manage_events/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/manage_events/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/manage_events/urls.py b/manage_events/urls.py new file mode 100644 index 0000000..7c6bc6d --- /dev/null +++ b/manage_events/urls.py @@ -0,0 +1,71 @@ +from django.urls import path +from . import views + +app_name = "manage_events" + +urlpatterns = [ + # Event Category + path( + "event-category/list/", + views.EventCategoryView.as_view(), + name="event_category_list", + ), + path( + "event-category/add/", + views.EventCategoryCreateOrUpdateView.as_view(), + name="event_category_add", + ), + path( + "event-category/edit//", + views.EventCategoryCreateOrUpdateView.as_view(), + name="event_category_edit", + ), + path( + "event-category/delete/", + views.EventCategoryDeleteView.as_view(), + name="event_category_delete", + ), + # EventMaster + path( + "event-master/list/", + views.EventMasterView.as_view(), + name="EventMaster_list", + ), + path( + "event-master/add/", + views.EventMasterCreateOrUpdateView.as_view(), + name="EventMaster_add", + ), + path( + "event-master/edit//", + views.EventMasterCreateOrUpdateView.as_view(), + name="EventMaster_edit", + ), + path( + "event-master/delete/", + views.EventMasterDeleteView.as_view(), + name="EventMaster_delete", + ), + # Event + path( + "event/list/", + views.EventView.as_view(), + name="event_list", + ), + path( + "event/add/", + views.EventCreateOrUpdateView.as_view(), + name="event_add", + ), + path( + "event/edit//", + views.EventCreateOrUpdateView.as_view(), + name="event_edit", + ), + path("event//", views.EventDetailView.as_view(), name="event_detail"), + path( + "event/delete/", + views.EventDeleteView.as_view(), + name="event_delete", + ), +] diff --git a/manage_events/views.py b/manage_events/views.py new file mode 100644 index 0000000..d706813 --- /dev/null +++ b/manage_events/views.py @@ -0,0 +1,352 @@ +from django.shortcuts import get_object_or_404, redirect, render +from accounts import resource_action +from manage_events.forms import ( + EventMasterForm, + EventCategoryForm, + EventForm, +) +from django.core.paginator import Paginator +from .models import EventMaster, Event, EventCategory +from django.views import generic +from django.contrib.auth.mixins import LoginRequiredMixin +from django.urls import reverse_lazy +from django.contrib import messages +from goodtimes import constants + +# Create your views here. + + +class EventCategoryCreateOrUpdateView(LoginRequiredMixin, generic.View): + # Set the page_name and resource + page_name = resource_action.RESOURCE_MANAGE_EVENTS + resource = resource_action.RESOURCE_MANAGE_EVENTS + + # Initialize the action as ACTION_CREATE (can change based on logic) + action = resource_action.ACTION_CREATE # Default action + + template_name = "manage_events/event_category_add.html" + model = EventCategory + form_class = EventCategoryForm + success_url = reverse_lazy("manage_events:event_category_list") + error_message = "An error occurred while saving the data." + + # Determine the success message dynamically based on whether it's an update or create + def get_success_message(self): + self.success_message = ( + constants.RECORD_CREATED if not self.object else constants.RECORD_UPDATED + ) + return self.success_message + + # Get the object (if exists) based on URL parameter 'pk' + def get_object(self): + pk = self.kwargs.get("pk") + return get_object_or_404(self.model, pk=pk) if pk else None + + # Add page_name and operation to the context + def get_context_data(self, **kwargs): + context = { + "page_name": self.page_name, + "operation": "Add" if not self.object else "Edit", + } + context.update(kwargs) # Include any additional context data passed to the view + return context + + def get(self, request, *args, **kwargs): + self.object = self.get_object() + + # If an object is found, change action to ACTION_UPDATE + if self.object is not None: + self.action = resource_action.ACTION_UPDATE + + form = self.form_class(instance=self.object) + context = self.get_context_data(form=form) + return render(request, self.template_name, context=context) + + def post(self, request, *args, **kwargs): + self.object = self.get_object() + + # If an object is found, change action to ACTION_UPDATE + if self.object is not None: + self.action = resource_action.ACTION_UPDATE + + form = self.form_class(request.POST, request.FILES, instance=self.object) + print("request.FILES: ", request.FILES) + if not form.is_valid(): + print(form.errors) + context = self.get_context_data(form=form) + return render(request, self.template_name, context=context) + form.save() + messages.success(self.request, self.get_success_message()) + return redirect(self.success_url) + + +class EventCategoryView(LoginRequiredMixin, generic.ListView): + page_name = resource_action.RESOURCE_MANAGE_EVENTS + resource = resource_action.RESOURCE_MANAGE_EVENTS + action = resource_action.ACTION_READ + model = EventCategory + template_name = "manage_events/event_category_list.html" + context_object_name = "event_category_obj" + + def get_queryset(self): + return super().get_queryset().filter(deleted=False) + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["page_name"] = self.page_name + return context + + +class EventCategoryDeleteView(LoginRequiredMixin, generic.View): + page_name = resource_action.RESOURCE_MANAGE_EVENTS + resource = resource_action.RESOURCE_MANAGE_EVENTS + action = resource_action.ACTION_DELETE + model = EventCategory + success_url = reverse_lazy("manage_events:event_category_list") + success_message = constants.RECORD_DELETED + error_message = constants.RECORD_NOT_FOUND + + def get(self, request, pk): + try: + type_obj = self.model.objects.get(id=pk) + type_obj.deleted = True + type_obj.active = False + type_obj.save() + messages.success(request, self.success_message) + except self.model.DoesNotExist: + messages.success(request, self.error_message) + + return redirect(self.success_url) + + +class EventMasterCreateOrUpdateView(LoginRequiredMixin, generic.View): + # Set the page_name and resource + page_name = resource_action.RESOURCE_MANAGE_EVENTS + resource = resource_action.RESOURCE_MANAGE_EVENTS + + # Initialize the action as ACTION_CREATE (can change based on logic) + action = resource_action.ACTION_CREATE # Default action + + template_name = "manage_events/EventMaster_add.html" + model = EventMaster + form_class = EventMasterForm + success_url = reverse_lazy("manage_events:EventMaster_list") + error_message = "An error occurred while saving the data." + + # Determine the success message dynamically based on whether it's an update or create + def get_success_message(self): + self.success_message = ( + constants.RECORD_CREATED if not self.object else constants.RECORD_UPDATED + ) + return self.success_message + + # Get the object (if exists) based on URL parameter 'pk' + def get_object(self): + pk = self.kwargs.get("pk") + return get_object_or_404(self.model, pk=pk) if pk else None + + # Add page_name and operation to the context + def get_context_data(self, **kwargs): + context = { + "page_name": self.page_name, + "operation": "Add" if not self.object else "Edit", + } + context.update(kwargs) # Include any additional context data passed to the view + return context + + def get(self, request, *args, **kwargs): + self.object = self.get_object() + + # If an object is found, change action to ACTION_UPDATE + if self.object is not None: + self.action = resource_action.ACTION_UPDATE + + form = self.form_class(instance=self.object) + context = self.get_context_data(form=form) + return render(request, self.template_name, context=context) + + def post(self, request, *args, **kwargs): + self.object = self.get_object() + + # If an object is found, change action to ACTION_UPDATE + if self.object is not None: + self.action = resource_action.ACTION_UPDATE + + form = self.form_class(request.POST, request.FILES, instance=self.object) + if not form.is_valid(): + print(form.errors) + context = self.get_context_data(form=form) + return render(request, self.template_name, context=context) + form.save() + messages.success(self.request, self.get_success_message()) + return redirect(self.success_url) + + +class EventMasterView(LoginRequiredMixin, generic.ListView): + page_name = resource_action.RESOURCE_MANAGE_EVENTS + resource = resource_action.RESOURCE_MANAGE_EVENTS + action = resource_action.ACTION_READ + model = EventMaster + template_name = "manage_events/EventMaster_list.html" + context_object_name = "EventMaster_obj" + + def get_queryset(self): + return super().get_queryset().filter(deleted=False) + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["page_name"] = self.page_name + return context + + +class EventMasterDeleteView(LoginRequiredMixin, generic.View): + page_name = resource_action.RESOURCE_MANAGE_EVENTS + resource = resource_action.RESOURCE_MANAGE_EVENTS + action = resource_action.ACTION_DELETE + model = EventMaster + success_url = reverse_lazy("manage_events:EventMaster_list") + success_message = constants.RECORD_DELETED + error_message = constants.RECORD_NOT_FOUND + + def get(self, request, pk): + try: + type_obj = self.model.objects.get(id=pk) + type_obj.deleted = True + type_obj.active = False + type_obj.save() + messages.success(request, self.success_message) + except self.model.DoesNotExist: + messages.success(request, self.error_message) + + return redirect(self.success_url) + + +class EventCreateOrUpdateView(LoginRequiredMixin, generic.View): + # Set the page_name and resource + page_name = resource_action.RESOURCE_MANAGE_EVENTS + resource = resource_action.RESOURCE_MANAGE_EVENTS + + # Initialize the action as ACTION_CREATE (can change based on logic) + action = resource_action.ACTION_CREATE # Default action + + template_name = "manage_events/event_add.html" + model = Event + form_class = EventForm + success_url = reverse_lazy("manage_events:event_list") + error_message = "An error occurred while saving the data." + + # Determine the success message dynamically based on whether it's an update or create + def get_success_message(self): + self.success_message = ( + constants.RECORD_CREATED if not self.object else constants.RECORD_UPDATED + ) + return self.success_message + + # Get the object (if exists) based on URL parameter 'pk' + def get_object(self): + pk = self.kwargs.get("pk") + return get_object_or_404(self.model, pk=pk) if pk else None + + # Add page_name and operation to the context + def get_context_data(self, **kwargs): + context = { + "page_name": self.page_name, + "operation": "Add" if not self.object else "Edit", + } + context.update(kwargs) # Include any additional context data passed to the view + return context + + def get(self, request, *args, **kwargs): + self.object = self.get_object() + + # If an object is found, change action to ACTION_UPDATE + if self.object is not None: + self.action = resource_action.ACTION_UPDATE + + form = self.form_class(instance=self.object) + context = self.get_context_data(form=form) + return render(request, self.template_name, context=context) + + def post(self, request, *args, **kwargs): + self.object = self.get_object() + + # If an object is found, change action to ACTION_UPDATE + if self.object is not None: + self.action = resource_action.ACTION_UPDATE + + form = self.form_class(request.POST, request.FILES, instance=self.object) + if not form.is_valid(): + print(form.errors) + context = self.get_context_data(form=form) + return render(request, self.template_name, context=context) + form.save() + messages.success(self.request, self.get_success_message()) + return redirect(self.success_url) + + +class EventView(LoginRequiredMixin, generic.ListView): + page_name = resource_action.RESOURCE_MANAGE_EVENTS + resource = resource_action.RESOURCE_MANAGE_EVENTS + action = resource_action.ACTION_READ + model = Event + template_name = "manage_events/event_list.html" + context_object_name = "event_obj" + paginate_by = 10 + + def get_queryset(self): + return super().get_queryset().filter(deleted=False, active=True, draft=False) + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["page_name"] = self.page_name + + # Paginate the queryset + queryset = self.get_queryset() + page_obj = Paginator(queryset, self.paginate_by) + page_number = self.request.GET.get( + "page" + ) # Get current page from URL parameter + page_obj = page_obj.get_page(page_number) + + context.update( + { + "events": page_obj.object_list, + "paginator": page_obj, + "is_paginated": page_obj.has_other_pages(), # Check if there are more pages + } + ) + + return context + + +class EventDetailView(generic.DetailView): + model = Event + template_name = "manage_events/event_details.html" + context_object_name = "event" + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + # Add additional context if necessary + return context + + +class EventDeleteView(LoginRequiredMixin, generic.View): + page_name = resource_action.RESOURCE_MANAGE_EVENTS + resource = resource_action.RESOURCE_MANAGE_EVENTS + action = resource_action.ACTION_DELETE + model = Event + success_url = reverse_lazy("manage_events:event_list") + success_message = constants.RECORD_DELETED + error_message = constants.RECORD_NOT_FOUND + + def get(self, request, pk): + try: + type_obj = self.model.objects.get(id=pk) + type_obj.deleted = True + type_obj.active = False + type_obj.save() + messages.success(request, self.success_message) + except self.model.DoesNotExist: + messages.success(request, self.error_message) + + return redirect(self.success_url) diff --git a/manage_referrals/__init__.py b/manage_referrals/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/manage_referrals/admin.py b/manage_referrals/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/manage_referrals/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/manage_referrals/api/serializers.py b/manage_referrals/api/serializers.py new file mode 100644 index 0000000..3f4cbab --- /dev/null +++ b/manage_referrals/api/serializers.py @@ -0,0 +1,39 @@ +import re + +from django.contrib.auth.hashers import make_password +from rest_framework import serializers + +from accounts.models import ( + IAmPrincipal, + IAmPrincipalType, + # IAmPrincipalKYCDetails, +) +from manage_referrals.models import ( + ReferralCode, + ReferralRecord, +) +from goodtimes import constants, date_utils + + +class ReferralCodeSerializer(serializers.ModelSerializer): + class Meta: + model = ReferralCode + fields = ["referral_code"] + + +class ReferralRecordSerializer(serializers.ModelSerializer): + name = serializers.SerializerMethodField() + join_at = serializers.SerializerMethodField() + + class Meta: + model = ReferralRecord + fields = ["name", "join_at"] + + def get_name(self, obj): + # Check if the referred_principal is set (not None) and get the full name + if obj.referred_principal: + return obj.referred_principal.get_full_name() + return None + + def get_join_at(self, obj): + return date_utils.format_date_to_string(obj.created_on) diff --git a/manage_referrals/api/urls.py b/manage_referrals/api/urls.py new file mode 100644 index 0000000..cf97349 --- /dev/null +++ b/manage_referrals/api/urls.py @@ -0,0 +1,16 @@ +from django.urls import path +from . import views + + +urlpatterns = [ + path( + "referral-code//", + views.ReferralCodeViews.as_view(), + name="referral_code", + ), + path( + "referral-record//", + views.ReferralRecordViews.as_view(), + name="referral_record", + ), +] diff --git a/manage_referrals/api/views.py b/manage_referrals/api/views.py new file mode 100644 index 0000000..dc35a89 --- /dev/null +++ b/manage_referrals/api/views.py @@ -0,0 +1,55 @@ +from rest_framework import status +from rest_framework.views import APIView +from django.conf import settings +from manage_referrals.models import ReferralCode, ReferralRecord +from goodtimes import constants + +from goodtimes.utils import ApiResponse +from .serializers import ( + ReferralCodeSerializer, + ReferralRecordSerializer, +) +from rest_framework.permissions import IsAuthenticated +from rest_framework_simplejwt.authentication import JWTAuthentication +from rest_framework.response import Response + + +class ReferralCodeViews(APIView): + authentication_classes = [JWTAuthentication] + permission_classes = [IsAuthenticated] + model = ReferralCode + serializer = ReferralCodeSerializer + + def get(self, request, *args, **kwargs): + referral_obj = self.model.filter_referral_code( + principal=request.user, principal_type=kwargs.get("principal_type") + ) + + serializer_obj = self.serializer(referral_obj, many=True) + + success_message = { + "status": status.HTTP_200_OK, + "message": constants.SUCCESS, + "data": serializer_obj.data, + } + return ApiResponse.success(**success_message) + + +class ReferralRecordViews(APIView): + authentication_classes = [JWTAuthentication] + permission_classes = [IsAuthenticated] + model = ReferralRecord + serializer = ReferralRecordSerializer + + def get(self, request, *args, **kwargs): + referral_obj = self.model.filter_invite_records( + referrer_principal=request.user, principal_type=kwargs.get("principal_type") + ) + + serializer_obj = self.serializer(referral_obj, many=True) + success_message = { + "status": status.HTTP_200_OK, + "message": constants.SUCCESS, + "data": {"count": referral_obj.count(), "record": serializer_obj.data}, + } + return ApiResponse.success(**success_message) diff --git a/manage_referrals/apps.py b/manage_referrals/apps.py new file mode 100644 index 0000000..96b0594 --- /dev/null +++ b/manage_referrals/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class ManageReferralsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "manage_referrals" diff --git a/manage_referrals/migrations/0001_initial.py b/manage_referrals/migrations/0001_initial.py new file mode 100644 index 0000000..065116e --- /dev/null +++ b/manage_referrals/migrations/0001_initial.py @@ -0,0 +1,265 @@ +# Generated by Django 5.0.2 on 2024-02-29 07:47 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ("accounts", "0001_initial"), + ("manage_subscriptions", "0001_initial"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="ReferralCode", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("active", models.BooleanField(default=True)), + ("deleted", models.BooleanField(default=False)), + ("created_on", models.DateTimeField(auto_now_add=True)), + ("modified_on", models.DateTimeField(auto_now=True)), + ("referral_code", models.CharField(max_length=50, unique=True)), + ( + "created_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="%(class)s_created", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "modified_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="%(class)s_modified", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "principal", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="principal_referral", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "principal_type", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="principal_type_referral", + to="accounts.iamprincipaltype", + ), + ), + ], + options={ + "db_table": "referral_code", + }, + ), + migrations.CreateModel( + name="ReferralRecord", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("active", models.BooleanField(default=True)), + ("deleted", models.BooleanField(default=False)), + ("created_on", models.DateTimeField(auto_now_add=True)), + ("modified_on", models.DateTimeField(auto_now=True)), + ( + "is_completed", + models.BooleanField( + default=False, + help_text="Indicates whether the referral is completed", + ), + ), + ( + "created_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="%(class)s_created", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "modified_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="%(class)s_modified", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "principal_type", + models.ForeignKey( + help_text="The type of principal associated with this referral", + on_delete=django.db.models.deletion.CASCADE, + to="accounts.iamprincipaltype", + ), + ), + ( + "referred_principal", + models.ForeignKey( + help_text="The principal who was referred", + on_delete=django.db.models.deletion.CASCADE, + related_name="referrals_by_referred", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "referrer_principal", + models.ForeignKey( + help_text="The principal who referred someone", + on_delete=django.db.models.deletion.CASCADE, + related_name="referrals_by_referrer", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "db_table": "referral_record", + }, + ), + migrations.CreateModel( + name="ReferralRecordReward", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("active", models.BooleanField(default=True)), + ("deleted", models.BooleanField(default=False)), + ("created_on", models.DateTimeField(auto_now_add=True)), + ("modified_on", models.DateTimeField(auto_now=True)), + ("coins", models.PositiveBigIntegerField()), + ("value", models.DecimalField(decimal_places=2, max_digits=14)), + ( + "created_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="%(class)s_created", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "modified_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="%(class)s_modified", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "referral_record", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="record_reward", + to="manage_referrals.referralrecord", + ), + ), + ( + "subscription", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="subscription_reward", + to="manage_subscriptions.subscription", + ), + ), + ], + options={ + "db_table": "referral_record_reward", + }, + ), + migrations.CreateModel( + name="ReferralTracking", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("active", models.BooleanField(default=True)), + ("deleted", models.BooleanField(default=False)), + ("created_on", models.DateTimeField(auto_now_add=True)), + ("modified_on", models.DateTimeField(auto_now=True)), + ("ip_address", models.GenericIPAddressField()), + ("user_agent", models.CharField(max_length=255)), + ( + "device_model", + models.CharField(blank=True, max_length=100, null=True), + ), + ( + "created_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="%(class)s_created", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "modified_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="%(class)s_modified", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "referral_record", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="record_tracking", + to="manage_referrals.referralrecord", + ), + ), + ], + options={ + "db_table": "referral_tracking", + }, + ), + ] diff --git a/manage_referrals/migrations/__init__.py b/manage_referrals/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/manage_referrals/models.py b/manage_referrals/models.py new file mode 100644 index 0000000..1fb6795 --- /dev/null +++ b/manage_referrals/models.py @@ -0,0 +1,158 @@ +from django.db import models +import random +import string +from accounts.models import BaseModel, IAmPrincipal, IAmPrincipalType +from manage_subscriptions.models import Subscription + + +# Create your models here. +class ReferralCode(BaseModel): + principal = models.ForeignKey( + IAmPrincipal, related_name="principal_referral", on_delete=models.CASCADE + ) + principal_type = models.ForeignKey( + IAmPrincipalType, + related_name="principal_type_referral", + on_delete=models.CASCADE, + ) + referral_code = models.CharField(max_length=50, unique=True) + + class Meta: + db_table = "referral_code" + + def __str__(self): + return f"{self.principal.first_name}" + + @classmethod + def filter_referral_code(cls, principal, principal_type): + return cls.objects.filter( + principal=principal, principal_type__name=principal_type + ) + + @classmethod + def generate_referral_code(cls, type, name): + """ + Generate a Referral Code. + + This method generates a unique referral code based on the provided 'type' and 'name'. + + Process: + 1. Ensure 'type' and 'name' are converted to uppercase. + 2. Create a 4-digit random number. + 3. Combine 'type', the first 3 characters of 'name' (or 'NIF' if empty), and the random number to form a code. + 4. Check if the generated code already exists in the database. If not, return it as a unique referral code. + 5. Repeat the process until a unique code is generated. + + The method ensures each generated code is unique to avoid conflicts. + """ + type = type.upper() + name = name[:3].upper() if name else "GDTM" + while True: + random_number = "".join(random.choice(string.digits) for _ in range(4)) + code = f"{type}{name}{random_number}" + if not ReferralCode.objects.filter(referral_code=code).exists(): + return code + + @classmethod + def create_referral_code_for_user_manager(cls, principal, principal_type): + """ + Create Referral Codes for Player and Merchant Principals. + + Process: + 1. Determine the principal types for Player and Merchant. + 2. Check if referral codes for Player and Merchant already exist for the principal. + 3. If not, create referral codes for Player and Merchant, associating them with the principal. + 4. Referral codes are generated based on the principal's first name and type (e.g., "PLA" for Player, "MER" for Merchant). + 5. Save the created referral codes in the database. + + This method ensures that a user has referral codes for both Player and Merchant roles when they don't already exist. + """ + user_type = IAmPrincipalType.get_principal_type(principal_type) + + user_code = cls.objects.filter( + principal=principal, + principal_type=user_type, + ).first() + + if not user_code: + user_code = cls( + principal=principal, + principal_type=user_type, + referral_code=cls.generate_referral_code( + type=str(user_type).upper(), name=principal.first_name + ), + ) + user_code.save() + + def save(self, *args, **kwargs): + super(ReferralCode, self).save(*args, **kwargs) + + +class ReferralRecord(BaseModel): + referrer_principal = models.ForeignKey( + IAmPrincipal, + on_delete=models.CASCADE, + related_name="referrals_by_referrer", + help_text="The principal who referred someone", + ) + referred_principal = models.ForeignKey( + IAmPrincipal, + on_delete=models.CASCADE, + related_name="referrals_by_referred", + help_text="The principal who was referred", + ) + principal_type = models.ForeignKey( + IAmPrincipalType, + on_delete=models.CASCADE, + help_text="The type of principal associated with this referral", + ) + is_completed = models.BooleanField( + default=False, help_text="Indicates whether the referral is completed" + ) + + class Meta: + db_table = "referral_record" + + def __str__(self): + return f"Referral ID: {self.id}, Referrar name: {self.referrer_principal.first_name}, Type: {self.principal_type.name}" + + @classmethod + def filter_invite_records(cls, referrer_principal, principal_type): + record_instance = cls.objects.filter( + referrer_principal=referrer_principal, principal_type__name=principal_type + ) + return record_instance + + @classmethod + def get_invite_count(cls, referrer_principal, principal_type): + filter_record = cls.filter_invite_records(referrer_principal, principal_type) + return filter_record.count() + + +class ReferralRecordReward(BaseModel): + referral_record = models.ForeignKey( + ReferralRecord, on_delete=models.CASCADE, related_name="record_reward" + ) + subscription = models.ForeignKey( + Subscription, on_delete=models.CASCADE, related_name="subscription_reward" + ) + coins = models.PositiveBigIntegerField() + value = models.DecimalField(max_digits=14, decimal_places=2) + + class Meta: + db_table = "referral_record_reward" + + +class ReferralTracking(BaseModel): + referral_record = models.ForeignKey( + ReferralRecord, on_delete=models.CASCADE, related_name="record_tracking" + ) + ip_address = models.GenericIPAddressField() + user_agent = models.CharField(max_length=255) + device_model = models.CharField(max_length=100, blank=True, null=True) + + class Meta: + db_table = "referral_tracking" + + def __str__(self): + return f"Referral Record ID: {self.referral_record.id}" diff --git a/manage_referrals/tests.py b/manage_referrals/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/manage_referrals/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/manage_referrals/views.py b/manage_referrals/views.py new file mode 100644 index 0000000..91ea44a --- /dev/null +++ b/manage_referrals/views.py @@ -0,0 +1,3 @@ +from django.shortcuts import render + +# Create your views here. diff --git a/manage_subscriptions/__init__.py b/manage_subscriptions/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/manage_subscriptions/admin.py b/manage_subscriptions/admin.py new file mode 100644 index 0000000..511b3e5 --- /dev/null +++ b/manage_subscriptions/admin.py @@ -0,0 +1,29 @@ +from django.contrib import admin +from .models import Plan, PrincipalSubscription, Subscription # Update this with the correct import path for your models + +# Plan ModelAdmin +class PlanAdmin(admin.ModelAdmin): + list_display = ('id', 'title', 'days') # Include 'id' field here + search_fields = ('title',) # Add search functionality by title + +# Register Plan with the admin site +admin.site.register(Plan, PlanAdmin) + +# Subscription ModelAdmin +class SubscriptionAdmin(admin.ModelAdmin): + list_display = ('id', 'title', 'plan', 'amount') # Include 'id' field here + list_select_related = ('plan',) # Optimizes queries for the plan field + search_fields = ('title', 'plan__title') # Add search functionality by title and plan's title + raw_id_fields = ('plan',) # Use a raw ID widget for the plan ForeignKey field + +# Register Subscription with the admin site +admin.site.register(Subscription, SubscriptionAdmin) + +class PrincipalSubscriptionAdmin(admin.ModelAdmin): + list_display = ('id', 'subscription', 'principal', 'is_paid', 'auto_renew', 'status', 'start_date', 'end_date') + list_filter = ('is_paid', 'auto_renew', 'status', 'cancelled') # Enable filtering by these fields + search_fields = ('subscription__title', 'principal__name', 'order_id') # Adjust 'principal__name' as necessary + raw_id_fields = ('subscription', 'principal') # Use raw ID widget for these ForeignKey fields + date_hierarchy = 'start_date' # Enables a date drill down by start_date + +admin.site.register(PrincipalSubscription, PrincipalSubscriptionAdmin) diff --git a/manage_subscriptions/api/serializers.py b/manage_subscriptions/api/serializers.py new file mode 100644 index 0000000..8c86c66 --- /dev/null +++ b/manage_subscriptions/api/serializers.py @@ -0,0 +1,8 @@ +from rest_framework import serializers +from manage_subscriptions.models import ( + PrincipalSubscription, +) # Adjust the import according to your project structure + + +class PrincipalSubscriptionSerializer(serializers.Serializer): + subscription = serializers.IntegerField() diff --git a/manage_subscriptions/api/urls.py b/manage_subscriptions/api/urls.py new file mode 100644 index 0000000..280ce51 --- /dev/null +++ b/manage_subscriptions/api/urls.py @@ -0,0 +1,11 @@ +from django.urls import path +from . import views +from rest_framework_simplejwt.views import ( + TokenRefreshView, +) + + +urlpatterns = [ + path('buy-subscription/', views.CreatePrincipalSubscriptionApi.as_view(), name='buy_subscription'), + path('test-webhook/', views.StripeWebhookTest.as_view(), name='webhook_test'), +] diff --git a/manage_subscriptions/api/views.py b/manage_subscriptions/api/views.py new file mode 100644 index 0000000..7dfdc17 --- /dev/null +++ b/manage_subscriptions/api/views.py @@ -0,0 +1,222 @@ +from datetime import timedelta +import datetime +from django.db import transaction +from django.shortcuts import get_object_or_404 +from django.utils import timezone +from rest_framework import status +from rest_framework.views import APIView +from django.conf import settings +import stripe +from accounts.models import IAmPrincipal +from goodtimes import constants, services +from manage_subscriptions.models import Subscription, PrincipalSubscription +from goodtimes.utils import ApiResponse +from accounts.resource_action import ( + PRINCIPAL_TYPE_EVENT_USER, + PRINCIPAL_TYPE_EVENT_MANAGER, + PRINCIPAL_TYPE_FREE_USER, +) +from rest_framework.permissions import IsAuthenticated, AllowAny +from rest_framework_simplejwt.authentication import JWTAuthentication +from .serializers import PrincipalSubscriptionSerializer +from django.views.decorators.csrf import csrf_exempt +from django.utils.decorators import method_decorator +from rest_framework.response import Response + + +class CreatePrincipalSubscriptionApi(APIView): + authentication_classes = [JWTAuthentication] + permission_classes = [IsAuthenticated] + stripe.api_key = settings.STRIPE_SECRET_KEY + + def post(self, request): + serializer = PrincipalSubscriptionSerializer(data=request.data) + + if serializer.is_valid(): + subscription_id = serializer.validated_data.get("subscription") + try: + subscription = Subscription.objects.get(id=subscription_id) + except Subscription.DoesNotExist: + return ApiResponse.error( + status=status.HTTP_404_NOT_FOUND, message="Subscription not found." + ) + + order_id = ( + "order_" + + str(timezone.localtime().timestamp()) + + str(request.user.email) + ) + print("order_id: ", order_id) + try: + payment_intent = stripe.PaymentIntent.create( + amount=int(subscription.amount * 100), + currency="INR", + description="Principal Subscription", + metadata={ + "principal": request.user.id, + "order_id": order_id, + "subscription": subscription.id, + }, + ) + return Response( + { + "client_secret": payment_intent.client_secret, + "message": "Payment intent created successfully", + } + ) + except stripe.error.StripeError as e: + # Handle Stripe-related errors + return Response({"error": str(e)}, status=400) + else: + fail_response = { + "status": status.HTTP_400_BAD_REQUEST, + "message": "Validation Failed", + "errors": serializer.errors, + } + return ApiResponse.error(**fail_response) + + +# class CreatePrincipalSubscriptionApi(APIView): +# authentication_classes = [JWTAuthentication] +# permission_classes = [IsAuthenticated] + +# def post(self, request): +# serializer = PrincipalSubscriptionSerializer(data=request.data) + +# if serializer.is_valid(): +# subscription_id = serializer.validated_data.get("subscription").id +# try: +# subscription = Subscription.objects.get(id=subscription_id) +# except Subscription.DoesNotExist: +# return ApiResponse.error( +# status=status.HTTP_404_NOT_FOUND, message="Subscription not found." +# ) + +# start_date = timezone.localtime().date() +# end_date = start_date + timedelta(days=subscription.plan.days) +# grace_period_end_date = end_date + timedelta(days=15) + +# # You can directly pass the additional fields as save method arguments +# instance = serializer.save( +# start_date=start_date, +# end_date=end_date, +# grace_period_end_date=grace_period_end_date, +# created_by=request.user, # Assuming your model has this field and you want to track who created the subscription +# ) + +# success_response = { +# "status": status.HTTP_201_CREATED, # Use 201 for successful resource creation +# "message": "Success", +# "data": serializer.data, +# } +# return ApiResponse.success(**success_response) + +# else: +# fail_response = { +# "status": status.HTTP_400_BAD_REQUEST, +# "message": "Validation Failed", +# "errors": serializer.errors, +# } +# return ApiResponse.error(**fail_response) + +import json +@method_decorator(csrf_exempt, name="dispatch") +class StripeWebhookTest(APIView): + authentication_classes = [] + permission_classes = [AllowAny] + + @transaction.atomic + def post(self, request): + stripe.api_key = settings.STRIPE_SECRET_KEY + payload = request.body + print("payload", payload) + sig_header = request.META["HTTP_STRIPE_SIGNATURE"] + # endpoint_secret = settings.endpoint_secret + endpoint_secret = ( + "whsec_ccf1f87295603cdd1733995ee2d3c0d6f74c7ceaf28916ea45114a54b7ce1d0f" + ) + event = None + + try: + # event = stripe.Webhook.construct_event(payload, sig_header, endpoint_secret) + event = stripe.Event.construct_from(json.loads(payload), stripe.api_key) + except ValueError as e: + value_error_response = { + "status": status.HTTP_400_BAD_REQUEST, + "message": constants.ERROR_OCCURR.format(str(e)), + "errors": str(e), + } + return ApiResponse.error(**value_error_response) + + except stripe.error.SignatureVerificationError as e: + signature_error_response = { + "status": status.HTTP_400_BAD_REQUEST, + "message": constants.ERROR_OCCURR.format(str(e)), + "errors": str(e), + } + return ApiResponse.error(**signature_error_response) + + if event["type"] == "payment_intent.succeeded": + payment_intent = event["data"]["object"] + print("Intent succ") + print("payment_intent: ", payment_intent) + + metadata = event.data.object.metadata + print("metadata: ", metadata) + principal = metadata.get("principal") + subscription = metadata.get("subscription") + print("principal: ", principal) + print("subscription: ", subscription) + + try: + principal = IAmPrincipal.objects.get(id=principal) + except IAmPrincipal.DoesNotExist as e: + error_response = { + "status": status.HTTP_404_NOT_FOUND, + "message": constants.RECORD_NOT_FOUND, + "errors": str(e), + } + return ApiResponse.error(**error_response) + + try: + subscription = Subscription.objects.get(id=subscription) + except IAmPrincipal.DoesNotExist as e: + error_response = { + "status": status.HTTP_404_NOT_FOUND, + "message": constants.RECORD_NOT_FOUND, + "errors": str(e), + } + return ApiResponse.error(**error_response) + today = timezone.localtime().date() + last_date = timedelta(days=subscription.plan.days) + principal_subscription = PrincipalSubscription.objects.create( + principal=principal, + subscription=subscription, + start_date=today, + end_date=today + last_date, + grace_period_end_date=last_date + timedelta(days=15), + ) + principal_subscription.save() + wallet_manager = services.WalletManager( + principal=principal, + principal_type=principal.principal_type, + ) + deposit_amount = event.data.object.amount + deposit_transaction = wallet_manager.deposit( + deposit_amount, "subscription payment" + ) + + print("Passed Through principal_wallet Object") + + success_response = { + "status": status.HTTP_200_OK, + "message": "Webhook received, payment succeeded", + } + return ApiResponse.success(**success_response) + + else: + intent_response = { + "status": status.HTTP_400_BAD_REQUEST, + "message": "Webhook received, but payment failed", + } + return ApiResponse.success(**intent_response) diff --git a/manage_subscriptions/apps.py b/manage_subscriptions/apps.py new file mode 100644 index 0000000..ea57b32 --- /dev/null +++ b/manage_subscriptions/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class ManageSubscriptionsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "manage_subscriptions" diff --git a/manage_subscriptions/forms.py b/manage_subscriptions/forms.py new file mode 100644 index 0000000..01f9c97 --- /dev/null +++ b/manage_subscriptions/forms.py @@ -0,0 +1,37 @@ +from django import forms +from manage_subscriptions.models import PrincipalSubscription, Subscription, Plan + + +class PlanForm(forms.ModelForm): + class Meta: + model = Plan + fields = ["title", "days"] # Include all fields you want from the model + + # You can add custom validation for Plan fields here if needed + # Example: + # def clean_title(self): + # title = self.cleaned_data.get('title') + # # Add your validation logic here + # return title + + +class SubscriptionForm(forms.ModelForm): + class Meta: + model = Subscription + fields = [ + "title", + "plan", + "amount", + ] # Include all fields you want from the model + + +class PrincipalSubscriptionForm(forms.ModelForm): + class Meta: + model = PrincipalSubscription + fields = "__all__" # Includes all fields from the model + widgets = { + 'start_date': forms.DateInput(attrs={'type': 'date'}), + 'end_date': forms.DateInput(attrs={'type': 'date'}), + 'grace_period_end_date': forms.DateInput(attrs={'type': 'date'}), + 'cancelled_date_time': forms.DateTimeInput(attrs={'type': 'datetime'}), + } diff --git a/manage_subscriptions/migrations/0001_initial.py b/manage_subscriptions/migrations/0001_initial.py new file mode 100644 index 0000000..a22233f --- /dev/null +++ b/manage_subscriptions/migrations/0001_initial.py @@ -0,0 +1,203 @@ +# Generated by Django 5.0.2 on 2024-02-29 07:47 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="Plan", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("active", models.BooleanField(default=True)), + ("deleted", models.BooleanField(default=False)), + ("created_on", models.DateTimeField(auto_now_add=True)), + ("modified_on", models.DateTimeField(auto_now=True)), + ("title", models.CharField(max_length=255)), + ("days", models.PositiveIntegerField()), + ( + "created_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="%(class)s_created", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "modified_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="%(class)s_modified", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "db_table": "plan", + }, + ), + migrations.CreateModel( + name="Subscription", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("active", models.BooleanField(default=True)), + ("deleted", models.BooleanField(default=False)), + ("created_on", models.DateTimeField(auto_now_add=True)), + ("modified_on", models.DateTimeField(auto_now=True)), + ("title", models.CharField(max_length=255)), + ( + "amount", + models.DecimalField(decimal_places=2, default=0.0, max_digits=14), + ), + ( + "created_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="%(class)s_created", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "modified_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="%(class)s_modified", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "plan", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="subscription_plan", + to="manage_subscriptions.plan", + ), + ), + ], + options={ + "db_table": "subscription", + }, + ), + migrations.CreateModel( + name="PrincipalSubscription", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("active", models.BooleanField(default=True)), + ("deleted", models.BooleanField(default=False)), + ("created_on", models.DateTimeField(auto_now_add=True)), + ("modified_on", models.DateTimeField(auto_now=True)), + ("is_paid", models.BooleanField(default=False)), + ("auto_renew", models.BooleanField(default=False)), + ( + "status", + models.CharField( + choices=[ + ("active", "Active"), + ("expired", "Expired"), + ("inactive", "Inactive"), + ], + default="active", + max_length=255, + ), + ), + ("start_date", models.DateField()), + ("end_date", models.DateField()), + ("order_id", models.CharField(blank=True, max_length=255, null=True)), + ("cancelled", models.BooleanField(default=False)), + ("cancelled_date_time", models.DateTimeField(blank=True, null=True)), + ("grace_period_end_date", models.DateField(blank=True, null=True)), + ( + "stripe_customer_id", + models.CharField(blank=True, max_length=255, null=True), + ), + ( + "payment_intent_id", + models.CharField(blank=True, max_length=255, null=True), + ), + ( + "payment_intent_client_secret", + models.CharField(blank=True, max_length=255, null=True), + ), + ( + "created_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="%(class)s_created", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "modified_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="%(class)s_modified", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "principal", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="principal_subscription", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "subscription", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="subscription_reference", + to="manage_subscriptions.subscription", + ), + ), + ], + options={ + "db_table": "principal_subscription", + }, + ), + ] diff --git a/manage_subscriptions/migrations/__init__.py b/manage_subscriptions/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/manage_subscriptions/models.py b/manage_subscriptions/models.py new file mode 100644 index 0000000..7e03594 --- /dev/null +++ b/manage_subscriptions/models.py @@ -0,0 +1,66 @@ +from django.db import models +from accounts.models import BaseModel, IAmPrincipal, IAmPrincipalType +from django.utils.translation import gettext_lazy as _ +# Create your models here. + + +class Plan(BaseModel): + title = models.CharField(max_length=255) + days = models.PositiveIntegerField() + + class Meta: + db_table = "plan" + + def __str__(self): + return self.title + + +class Subscription(BaseModel): + title = models.CharField(max_length=255) + plan = models.ForeignKey( + Plan, related_name="subscription_plan", on_delete=models.CASCADE + ) + amount = models.DecimalField(max_digits=14, decimal_places=2, default=0.00) + + class Meta: + db_table = "subscription" + + def __str__(self): + return self.title + + +class SubscriptionStatus(models.TextChoices): + ACTIVE = 'active', _('Active') + EXPIRED = 'expired', _('Expired') + INACTIVE = 'inactive', _('Inactive') + + +class PrincipalSubscription(BaseModel): + subscription = models.ForeignKey( + Subscription, related_name="subscription_reference", on_delete=models.CASCADE + ) + principal = models.ForeignKey( + IAmPrincipal, related_name="principal_subscription", on_delete=models.CASCADE + ) + is_paid = models.BooleanField(default=False) + auto_renew = models.BooleanField(default=False) + status = models.CharField( + max_length=255, + choices=SubscriptionStatus.choices, + default=SubscriptionStatus.ACTIVE, + ) + start_date = models.DateField() + end_date = models.DateField() + order_id = models.CharField(max_length=255, null=True, blank=True) + cancelled = models.BooleanField(default=False) + cancelled_date_time = models.DateTimeField(null=True, blank=True) + grace_period_end_date = models.DateField(null=True, blank=True) + stripe_customer_id = models.CharField(max_length=255, null=True, blank=True) + payment_intent_id = models.CharField(max_length=255, null=True, blank=True) + payment_intent_client_secret = models.CharField(max_length=255, null=True, blank=True) + + class Meta: + db_table = "principal_subscription" + + def __str__(self): + return f"{self.subscription} - {self.principal.first_name}" diff --git a/manage_subscriptions/tests.py b/manage_subscriptions/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/manage_subscriptions/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/manage_subscriptions/urls.py b/manage_subscriptions/urls.py new file mode 100644 index 0000000..16bf158 --- /dev/null +++ b/manage_subscriptions/urls.py @@ -0,0 +1,58 @@ +from django.urls import path +from . import views + +app_name = "manage_subscriptions" + +urlpatterns = [ + path( + "subscription/list/", views.SubscriptionView.as_view(), name="subscription_list" + ), + path( + "subscription/add/", + views.SubscriptionCreateOrUpdateView.as_view(), + name="subscription_add", + ), + path( + "subscription/edit//", + views.SubscriptionCreateOrUpdateView.as_view(), + name="subscription_edit", + ), + path( + "subscription/delete/", + views.SubscriptionDeleteView.as_view(), + name="subscription_delete", + ), + # PLANS + path("plan/list/", views.PlanView.as_view(), name="plan_list"), + path( + "plan/add/", + views.PlanCreateOrUpdateView.as_view(), + name="plan_add", + ), + path( + "plan/edit//", + views.PlanCreateOrUpdateView.as_view(), + name="plan_edit", + ), + path( + "plan/delete/", + views.PlanDeleteView.as_view(), + name="plan_delete", + ), + path("principal_subscription/list/", views.PrincipalSubscriptionView.as_view(), name="principal_subscriptions_list"), + path( + "principal_subscription/add/", + views.PrincipalSubscriptionCreateOrUpdateView.as_view(), + name="principal_subscription_add", + ), + path( + "principal_subscription/edit//", + views.PrincipalSubscriptionCreateOrUpdateView.as_view(), + name="principal_subscription_edit", + ), + path( + "principal_subscription/delete/", + views.PrincipalSubscriptionDeleteView.as_view(), + name="principal_subscription_delete", + ), +] diff --git a/manage_subscriptions/views.py b/manage_subscriptions/views.py new file mode 100644 index 0000000..8e7c4aa --- /dev/null +++ b/manage_subscriptions/views.py @@ -0,0 +1,321 @@ +from django.shortcuts import get_object_or_404, redirect, render +from accounts import resource_action +from manage_subscriptions.forms import ( + PlanForm, + SubscriptionForm, + PrincipalSubscriptionForm, +) +from .models import Plan, Subscription, PrincipalSubscription +from django.views import generic +from django.contrib.auth.mixins import LoginRequiredMixin +from django.urls import reverse_lazy +from django.contrib import messages +from goodtimes import constants + +# Create your views here. + + +class SubscriptionCreateOrUpdateView(LoginRequiredMixin, generic.View): + # Set the page_name and resource + page_name = resource_action.RESOURCE_MANAGE_SUBSCRIPTIONS + resource = resource_action.RESOURCE_MANAGE_SUBSCRIPTIONS + + # Initialize the action as ACTION_CREATE (can change based on logic) + action = resource_action.ACTION_CREATE # Default action + + template_name = "manage_subscriptions/subscription_add.html" + model = Subscription + form_class = SubscriptionForm + success_url = reverse_lazy("manage_subscriptions:subscription_list") + error_message = "An error occurred while saving the data." + + # Determine the success message dynamically based on whether it's an update or create + def get_success_message(self): + self.success_message = ( + constants.RECORD_CREATED if not self.object else constants.RECORD_UPDATED + ) + return self.success_message + + # Get the object (if exists) based on URL parameter 'pk' + def get_object(self): + pk = self.kwargs.get("pk") + return get_object_or_404(self.model, pk=pk) if pk else None + + # Add page_name and operation to the context + def get_context_data(self, **kwargs): + context = { + "page_name": self.page_name, + "operation": "Add" if not self.object else "Edit", + } + context.update(kwargs) # Include any additional context data passed to the view + return context + + def get(self, request, *args, **kwargs): + self.object = self.get_object() + + # If an object is found, change action to ACTION_UPDATE + if self.object is not None: + self.action = resource_action.ACTION_UPDATE + + form = self.form_class(instance=self.object) + context = self.get_context_data(form=form) + return render(request, self.template_name, context=context) + + def post(self, request, *args, **kwargs): + self.object = self.get_object() + + # If an object is found, change action to ACTION_UPDATE + if self.object is not None: + self.action = resource_action.ACTION_UPDATE + + form = self.form_class(request.POST, instance=self.object) + if not form.is_valid(): + print(form.errors) + context = self.get_context_data(form=form) + return render(request, self.template_name, context=context) + form.save() + messages.success(self.request, self.get_success_message()) + return redirect(self.success_url) + + +class SubscriptionView(LoginRequiredMixin, generic.ListView): + page_name = resource_action.RESOURCE_MANAGE_SUBSCRIPTIONS + resource = resource_action.RESOURCE_MANAGE_SUBSCRIPTIONS + action = resource_action.ACTION_READ + model = Subscription + template_name = "manage_subscriptions/subscription_list.html" + context_object_name = "subscription_obj" + + def get_queryset(self): + return super().get_queryset().filter(deleted=False) + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["page_name"] = self.page_name + return context + + +class SubscriptionDeleteView(LoginRequiredMixin, generic.View): + page_name = resource_action.RESOURCE_MANAGE_SUBSCRIPTIONS + resource = resource_action.RESOURCE_MANAGE_SUBSCRIPTIONS + action = resource_action.ACTION_DELETE + model = Subscription + success_url = reverse_lazy("manage_subscriptions:subscription_list") + success_message = constants.RECORD_DELETED + error_message = constants.RECORD_NOT_FOUND + + def get(self, request, pk): + try: + type_obj = self.model.objects.get(id=pk) + type_obj.deleted = True + type_obj.active = False + type_obj.save() + messages.success(request, self.success_message) + except self.model.DoesNotExist: + messages.success(request, self.error_message) + + return redirect(self.success_url) + + +class PlanCreateOrUpdateView(LoginRequiredMixin, generic.View): + # Set the page_name and resource + page_name = resource_action.RESOURCE_MANAGE_SUBSCRIPTIONS + resource = resource_action.RESOURCE_MANAGE_SUBSCRIPTIONS + + # Initialize the action as ACTION_CREATE (can change based on logic) + action = resource_action.ACTION_CREATE # Default action + + template_name = "manage_subscriptions/plan_add.html" + model = Plan + form_class = PlanForm + success_url = reverse_lazy("manage_subscriptions:plan_list") + error_message = "An error occurred while saving the data." + + # Determine the success message dynamically based on whether it's an update or create + def get_success_message(self): + self.success_message = ( + constants.RECORD_CREATED if not self.object else constants.RECORD_UPDATED + ) + return self.success_message + + # Get the object (if exists) based on URL parameter 'pk' + def get_object(self): + pk = self.kwargs.get("pk") + return get_object_or_404(self.model, pk=pk) if pk else None + + # Add page_name and operation to the context + def get_context_data(self, **kwargs): + context = { + "page_name": self.page_name, + "operation": "Add" if not self.object else "Edit", + } + context.update(kwargs) # Include any additional context data passed to the view + return context + + def get(self, request, *args, **kwargs): + self.object = self.get_object() + + # If an object is found, change action to ACTION_UPDATE + if self.object is not None: + self.action = resource_action.ACTION_UPDATE + + form = self.form_class(instance=self.object) + context = self.get_context_data(form=form) + return render(request, self.template_name, context=context) + + def post(self, request, *args, **kwargs): + self.object = self.get_object() + + # If an object is found, change action to ACTION_UPDATE + if self.object is not None: + self.action = resource_action.ACTION_UPDATE + + form = self.form_class(request.POST, instance=self.object) + if not form.is_valid(): + print(form.errors) + context = self.get_context_data(form=form) + return render(request, self.template_name, context=context) + form.save() + messages.success(self.request, self.get_success_message()) + return redirect(self.success_url) + + +class PlanView(LoginRequiredMixin, generic.ListView): + page_name = resource_action.RESOURCE_MANAGE_SUBSCRIPTIONS + resource = resource_action.RESOURCE_MANAGE_SUBSCRIPTIONS + action = resource_action.ACTION_READ + model = Plan + template_name = "manage_subscriptions/plan_list.html" + context_object_name = "plan_obj" + + def get_queryset(self): + return super().get_queryset().filter(deleted=False) + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["page_name"] = self.page_name + return context + + +class PlanDeleteView(LoginRequiredMixin, generic.View): + page_name = resource_action.RESOURCE_MANAGE_SUBSCRIPTIONS + resource = resource_action.RESOURCE_MANAGE_SUBSCRIPTIONS + action = resource_action.ACTION_DELETE + model = Plan + success_url = reverse_lazy("manage_subscriptions:plan_list") + success_message = constants.RECORD_DELETED + error_message = constants.RECORD_NOT_FOUND + + def get(self, request, pk): + try: + type_obj = self.model.objects.get(id=pk) + type_obj.deleted = True + type_obj.active = False + type_obj.save() + messages.success(request, self.success_message) + except self.model.DoesNotExist: + messages.success(request, self.error_message) + + return redirect(self.success_url) + + +class PrincipalSubscriptionCreateOrUpdateView(LoginRequiredMixin, generic.View): + # Set the page_name and resource + page_name = resource_action.RESOURCE_MANAGE_SUBSCRIPTIONS + resource = resource_action.RESOURCE_MANAGE_SUBSCRIPTIONS + + # Initialize the action as ACTION_CREATE (can change based on logic) + action = resource_action.ACTION_CREATE # Default action + + template_name = "manage_subscriptions/principal_subscription_add.html" + model = PrincipalSubscription + form_class = PrincipalSubscriptionForm + success_url = reverse_lazy("manage_subscriptions:principal_subscriptions_list") + error_message = "An error occurred while saving the data." + + # Determine the success message dynamically based on whether it's an update or create + def get_success_message(self): + self.success_message = ( + constants.RECORD_CREATED if not self.object else constants.RECORD_UPDATED + ) + return self.success_message + + # Get the object (if exists) based on URL parameter 'pk' + def get_object(self): + pk = self.kwargs.get("pk") + return get_object_or_404(self.model, pk=pk) if pk else None + + # Add page_name and operation to the context + def get_context_data(self, **kwargs): + context = { + "page_name": self.page_name, + "operation": "Add" if not self.object else "Edit", + } + context.update(kwargs) # Include any additional context data passed to the view + return context + + def get(self, request, *args, **kwargs): + self.object = self.get_object() + + # If an object is found, change action to ACTION_UPDATE + if self.object is not None: + self.action = resource_action.ACTION_UPDATE + + form = self.form_class(instance=self.object) + context = self.get_context_data(form=form) + return render(request, self.template_name, context=context) + + def post(self, request, *args, **kwargs): + self.object = self.get_object() + + # If an object is found, change action to ACTION_UPDATE + if self.object is not None: + self.action = resource_action.ACTION_UPDATE + + form = self.form_class(request.POST, instance=self.object) + if not form.is_valid(): + print(form.errors) + context = self.get_context_data(form=form) + return render(request, self.template_name, context=context) + form.save() + messages.success(self.request, self.get_success_message()) + return redirect(self.success_url) + + +class PrincipalSubscriptionView(LoginRequiredMixin, generic.ListView): + page_name = resource_action.RESOURCE_MANAGE_SUBSCRIPTIONS + resource = resource_action.RESOURCE_MANAGE_SUBSCRIPTIONS + action = resource_action.ACTION_READ + model = PrincipalSubscription + template_name = "manage_subscriptions/principal_subscriptions_list.html" + context_object_name = "principal_subscription_obj" + + def get_queryset(self): + return super().get_queryset().filter(deleted=False) + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["page_name"] = self.page_name + return context + + +class PrincipalSubscriptionDeleteView(LoginRequiredMixin, generic.View): + page_name = resource_action.RESOURCE_MANAGE_SUBSCRIPTIONS + resource = resource_action.RESOURCE_MANAGE_SUBSCRIPTIONS + action = resource_action.ACTION_DELETE + model = PrincipalSubscription + success_url = reverse_lazy("manage_subscriptions:principal_subscriptions_list") + success_message = constants.RECORD_DELETED + error_message = constants.RECORD_NOT_FOUND + + def get(self, request, pk): + try: + type_obj = self.model.objects.get(id=pk) + type_obj.deleted = True + type_obj.active = False + type_obj.save() + messages.success(request, self.success_message) + except self.model.DoesNotExist: + messages.success(request, self.error_message) + + return redirect(self.success_url) \ No newline at end of file diff --git a/manage_wallets/__init__.py b/manage_wallets/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/manage_wallets/admin.py b/manage_wallets/admin.py new file mode 100644 index 0000000..fd17a29 --- /dev/null +++ b/manage_wallets/admin.py @@ -0,0 +1,60 @@ +from django.contrib import admin +from manage_wallets import models + +# Register your models here. + + +class WalletAdmin(admin.ModelAdmin): + list_display = ( + "id", + "principal", + "balance", + "deposit", + "withdrawal_balance", + "active", + "deleted", + "created_on", + "modified_on", + "created_by", + "modified_by", + ) + list_filter = ("principal",) # You can filter the list by other fields if needed + search_fields = ( + "principal__username", + ) # Example search field (replace with the relevant field) + + +admin.site.register(models.Wallet, WalletAdmin) + + +# class TransactionAdmin(admin.ModelAdmin): +# list_display = ('principal', 'principal_type', 'transaction_type', 'transaction_status', 'amount', 'active', 'deleted', 'created_on', 'modified_on', 'created_by', 'modified_by') +# list_filter = ('principal', 'transaction_type', 'transaction_status') +# search_fields = ('principal__username',) +# admin.site.register(models.Transaction, TransactionAdmin) + + +@admin.register(models.Transaction) +class TransactionAdmin(admin.ModelAdmin): + list_display = ( + "id", + "principal", + "principal_type", + "transaction_type", + "transaction_status", + "amount", + "comment", + "order_id", + "product_id", + "reference_id", + "payment_method", + "principal_subscription", + "active", + "deleted", + "created_on", + "modified_on", + "created_by", + "modified_by", + ) + list_filter = ("transaction_type", "transaction_status") + search_fields = ["order_id"] diff --git a/manage_wallets/api/serializers.py b/manage_wallets/api/serializers.py new file mode 100644 index 0000000..cf6a21a --- /dev/null +++ b/manage_wallets/api/serializers.py @@ -0,0 +1,45 @@ +from rest_framework import serializers +from accounts.models import IAmPrincipal, IAmPrincipalType +from goodtimes import constants +from manage_wallets import models + + +class WalletSerializer(serializers.ModelSerializer): + class Meta: + model = models.Wallet + exclude = [ + "principal", + "id", + "active", + "deleted", + "created_on", + "modified_on", + "created_by", + "modified_by", + ] + +class TransactionSerializer(serializers.ModelSerializer): + class Meta: + model = models.Transaction + fields = "__all__" + + +# class MerchantDepositSerializer(serializers.Serializer): +# principal_type = serializers.CharField() +# field = serializers.CharField() +# amount = serializers.DecimalField(max_digits=14, decimal_places=2) + + +class MerchantDepositSerializer(serializers.Serializer): + principal_type = serializers.CharField() + field = serializers.ChoiceField(choices=[ + ('player_balance', 'Player Balance'), + ('merchant_balance', 'Merchant Balance'), + ('player_deposit', 'Player Deposit'), + ('merchant_deposit', 'Merchant Deposit'), + ('player_bonus', 'Player Bonus'), + ('merchant_bonus', 'Merchant Bonus'), + ('player_winning', 'Player Winning'), + ('merchant_commission', 'Merchant Commission'), + ]) + amount = serializers.DecimalField(max_digits=14, decimal_places=2) diff --git a/manage_wallets/api/urls.py b/manage_wallets/api/urls.py new file mode 100644 index 0000000..76b2d34 --- /dev/null +++ b/manage_wallets/api/urls.py @@ -0,0 +1,17 @@ +from django.urls import path +from . import views + +app_name = "manage_wallet_api" + +urlpatterns = [ + + path('become-a-merchant//', views.BecomeAMerchantView.as_view(), name='become_a_merchant'), + path('webhook-test/', views.StripeWebhookTest.as_view(), name='webhook_test'), + path('postman-test/', views.TestWebhookAPI.as_view(), name='postman_test'), + path('postman-test-withdraw/', views.TestWebhookAPIWithdraw.as_view(), name='postman_test_withdraw'), + path('check/', views.CheckWallet.as_view(), name='check_wallet'), + path('is-merchant/', views.IsMerchant.as_view(), name='is_merchant'), + path('transactions/', views.TransactionView.as_view(), name='transactions'), + path('merchant-deposit/', views.MerchantDeposit.as_view(), name='merchant_deposit'), + +] diff --git a/manage_wallets/api/views.py b/manage_wallets/api/views.py new file mode 100644 index 0000000..116447c --- /dev/null +++ b/manage_wallets/api/views.py @@ -0,0 +1,374 @@ +import json +from django.shortcuts import get_object_or_404 +import requests +from rest_framework import status +from rest_framework.response import Response +from rest_framework.views import APIView +from django.views.decorators.csrf import csrf_exempt +from django.utils.decorators import method_decorator +from accounts.models import IAmPrincipal, IAmPrincipalType +from goodtimes import services, constants +from decimal import Decimal +from manage_wallets import models +from django.conf import settings +from . import serializers +from goodtimes.utils import ApiResponse +# from PayTm import Checksum, check +# from Paytm_Python.paytmchecksum import PaytmChecksum +# from paytmchecksum import PaytmChecksum + +# from nifty11_project.services import SMSError, SMSService +from rest_framework.permissions import AllowAny, IsAuthenticated +from rest_framework_simplejwt.authentication import JWTAuthentication +import stripe + + +class BecomeAMerchantView(APIView): + authentication_classes = [JWTAuthentication] + permission_classes = [IsAuthenticated] + stripe.api_key = settings.STRIPE_SECRET_KEY + + def post(self, request, type): + print(request.data) + amount = 1000 + principal_type = request.data["principal_type"] + try: + payment_intent = stripe.PaymentIntent.create( + amount=100000, + currency="INR", + description="Merchant Registration", + metadata={ + "principal_id": request.user.id, + "principal_type": principal_type, + }, + ) + # payment_intent_id = payment_intent.id # Get the payment_intent ID + + return Response( + { + "client_secret": payment_intent.client_secret, + "message": "Payment intent created successfully", + } + ) + except stripe.error.StripeError as e: + # Handle Stripe-related errors + return Response({"error": str(e)}, status=400) + + +@method_decorator(csrf_exempt, name="dispatch") +class StripeWebhookTest(APIView): + authentication_classes = [] + permission_classes = [AllowAny] + + def post(self, request): + stripe.api_key = settings.STRIPE_SECRET_KEY + payload = request.body + print("payload", payload) + sig_header = request.META["HTTP_STRIPE_SIGNATURE"] + # endpoint_secret = settings.STRIPE_WEBHOOK_SECRET + endpoint_secret = ( + "whsec_ccf1f87295603cdd1733995ee2d3c0d6f74c7ceaf28916ea45114a54b7ce1d0f" + ) + event = None + + try: + event = stripe.Webhook.construct_event(payload, sig_header, endpoint_secret) + except ValueError as e: + value_error_response = { + "status": status.HTTP_400_BAD_REQUEST, + "message": constants.ERROR_OCCURR.format(str(e)), + "errors": str(e), + } + return ApiResponse.error(**value_error_response) + + except stripe.error.SignatureVerificationError as e: + signature_error_response = { + "status": status.HTTP_400_BAD_REQUEST, + "message": constants.ERROR_OCCURR.format(str(e)), + "errors": str(e), + } + return ApiResponse.error(**signature_error_response) + + if event["type"] == "payment_intent.succeeded": + payment_intent = event["data"]["object"] + print("Intent succ") + print("payment_intent: ", payment_intent) + + metadata = event.data.object.metadata + print("metadata: ", metadata) + principal_id = metadata.get("principal_id") + principal_type = metadata.get("principal_type") + print("user_id: ", principal_id) + print("user_type: ", principal_type) + + try: + principal = IAmPrincipal.objects.get(id=principal_id) + except IAmPrincipal.DoesNotExist as e: + error_response = { + "status": status.HTTP_404_NOT_FOUND, + "message": constants.RECORD_NOT_FOUND, + "errors": str(e), + } + return ApiResponse.error(**error_response) + + wallet_manager = services.WalletManager( + principal=get_object_or_404(IAmPrincipal, id=principal_id), + principal_type=principal_type, + ) + deposit_amount = event.data.object.amount + deposit_transaction = wallet_manager.deposit( + deposit_amount, "merchant_deposit" + ) + + print("Passed Through principal_wallet Object") + + success_response = { + "status": status.HTTP_200_OK, + "message": "Webhook received, payment succeeded", + } + return ApiResponse.success(**success_response) + + else: + intent_response = { + "status": status.HTTP_400_BAD_REQUEST, + "message": "Webhook received, but payment failed", + } + return ApiResponse.success(**intent_response) + + +class CheckWallet(APIView): + authentication_classes = [JWTAuthentication] + permission_classes = [IsAuthenticated] + model = models.Wallet + + def get(self, request): + try: + # Get the Wallet associated with the request.user + wallet_obj = models.Wallet.objects.get(principal_id=request.user.id) + + # Serialize the wallet data + serializer = serializers.WalletSerializer(wallet_obj) + + return Response({"success": True, "data": serializer.data}) + except Exception as e: + # Handle any exceptions and return an error response + error_response = { + "status": status.HTTP_500_INTERNAL_SERVER_ERROR, + "message": constants.INTERNAL_SERVER_ERROR, + "errors": str(e), + } + return ApiResponse.error(**error_response) + + +class IsMerchant(APIView): + authentication_classes = [JWTAuthentication] + permission_classes = [IsAuthenticated] + model = models.Wallet + + def get(self, request): + try: + # Get the Wallet associated with the request.user + wallet_obj = models.Wallet.objects.get(principal_id=request.user.id) + merchant_deposit = wallet_obj.merchant_deposit + if merchant_deposit >= 1.00: + success_response = { + "status": status.HTTP_200_OK, + "message": constants.SUCCESS, + "data": {"is_merchant": True}, + } + return ApiResponse.success(**success_response) + data_response = { + "status": status.HTTP_200_OK, + "message": constants.SUCCESS, + "data": {"is_merchant": False}, + } + return ApiResponse.success(**data_response) + except Exception as e: + # Handle any exceptions and return an error response + error_response = { + "status": status.HTTP_500_INTERNAL_SERVER_ERROR, + "message": constants.INTERNAL_SERVER_ERROR, + "errors": str(e), + } + return ApiResponse.error(**error_response) + + +class TransactionView(APIView): + authentication_classes = [JWTAuthentication] + permission_classes = [IsAuthenticated] + + def get(self, request): + queryset = models.Transaction.objects.filter(principal_id=request.user.id) + serializer = serializers.TransactionSerializer(queryset, many=True) + + response = { + "status": status.HTTP_200_OK, + "message": constants.SUCCESS, + "data": serializer.data, + } + + return ApiResponse.success(**response) + + +class TestWebhookAPI(APIView): + authentication_classes = [] + permission_classes = [AllowAny] + # event = None + + def post(self, request): + event = request.data.get("event", {}) + print(event) + # return Response({"data": data}) + + if event.get("type") == "payment_intent.succeeded": + payment_intent = event.get("data", {}).get("object", {}) + print("Intent succ") + print("payment_intent: ", payment_intent) + + metadata = payment_intent.get("metadata", {}) + print("metadata: ", metadata) + principal_id = metadata.get("principal_id") + principal_type = metadata.get("principal_type") + print("user_id: ", principal_id) + print("user_type: ", principal_type) + + try: + principal = IAmPrincipal.objects.get(id=principal_id) + except IAmPrincipal.DoesNotExist as e: + principal_error_response = { + "status": status.HTTP_404_NOT_FOUND, + "message": constants.RECORD_NOT_FOUND, + "errors": str(e), + } + return ApiResponse.error(**principal_error_response) + + wallet_manager = services.WalletManager( + principal=get_object_or_404(IAmPrincipal, id=principal_id), + principal_type=principal_type, + ) + deposit_amount = event.get("data", {}).get("object", {}).get("amount", {}) + deposit_transaction = wallet_manager.deposit( + deposit_amount, "merchant_deposit" + ) + + print("Passed Through principal_wallet Object") + + success_response = { + "status": status.HTTP_200_OK, + "message": "Webhook received, payment succeeded", + } + return ApiResponse.success(**success_response) + + else: + intent_response = { + "status": status.HTTP_400_BAD_REQUEST, + "message": "Webhook received, but payment failed", + } + return ApiResponse.success(**intent_response) + + +class TestWebhookAPIWithdraw(APIView): + authentication_classes = [] + permission_classes = [AllowAny] + # event = None + + def post(self, request): + event = request.data.get("event", {}) + print(event) + # return Response({"data": data}) + + if event.get("type") == "payment_intent.succeeded": + payment_intent = event.get("data", {}).get("object", {}) + print("Intent succ") + print("payment_intent: ", payment_intent) + + metadata = payment_intent.get("metadata", {}) + print("metadata: ", metadata) + principal_id = metadata.get("principal_id") + principal_type = metadata.get("principal_type") + print("user_id: ", principal_id) + print("user_type: ", principal_type) + + try: + principal = IAmPrincipal.objects.get(id=principal_id) + except IAmPrincipal.DoesNotExist as e: + error_response = { + "status": status.HTTP_404_NOT_FOUND, + "message": constants.RECORD_NOT_FOUND, + "errors": str(e), + } + return ApiResponse.error(**error_response) + + wallet_manager = services.WalletManager( + principal=get_object_or_404(IAmPrincipal, id=principal_id), + principal_type=principal_type, + ) + withdraw_amount = event.get("data", {}).get("object", {}).get("amount", {}) + try: + withdraw_transaction = wallet_manager.withdraw( + withdraw_amount, "player_deposit" + ) + except Exception as e: + exception_response = { + "status": status.HTTP_400_BAD_REQUEST, + "message": constants.WITHDRAWAL_FAILED, + "errors": str(e), + } + return ApiResponse.error(**exception_response) + + print("Passed Through principal_wallet Object") + + success_response = { + "status": status.HTTP_200_OK, + "message": "Webhook received, payment succeeded", + } + return ApiResponse.success(**success_response) + + else: + intent_response = { + "status": status.HTTP_400_BAD_REQUEST, + "message": "Webhook received, but payment failed", + } + return ApiResponse.success(**intent_response) + + +class MerchantDeposit(APIView): + authentication_classes = [JWTAuthentication] + permission_classes = [IsAuthenticated] + + def post(self, request): + # print(request.data) + serializer = serializers.MerchantDepositSerializer(data=request.data) + # print("serializer: ", serializer) + if serializer.is_valid(): + print(serializer.validated_data) + wallet_manager = services.WalletManager( + principal=request.user, + principal_type=IAmPrincipalType.objects.filter( + name=serializer.validated_data["principal_type"] + ).first(), + ) + deposit_amount = serializer.validated_data["amount"] + deposit_field = serializer.validated_data["field"] + + deposit_transaction = wallet_manager.deposit(deposit_amount, deposit_field) + + print("Passed Through principal_wallet Object") + current_balance = models.Wallet.objects.get(principal=request.user) + success_response = { + "status": status.HTTP_200_OK, + "message": "Deposit Received", + "data": { + **serializer.data, + "merchant_deposit": current_balance.merchant_deposit, + }, + } + return ApiResponse.success(**success_response) + + else: + intent_response = { + "status": status.HTTP_400_BAD_REQUEST, + "message": "Deposit Failed", + "errors": serializer.errors, + } + return ApiResponse.error(**intent_response) diff --git a/manage_wallets/apps.py b/manage_wallets/apps.py new file mode 100644 index 0000000..38b2d75 --- /dev/null +++ b/manage_wallets/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class ManageWalletsConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'manage_wallets' diff --git a/manage_wallets/migrations/0001_initial.py b/manage_wallets/migrations/0001_initial.py new file mode 100644 index 0000000..1b6709e --- /dev/null +++ b/manage_wallets/migrations/0001_initial.py @@ -0,0 +1,193 @@ +# Generated by Django 5.0.2 on 2024-02-29 07:47 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ("accounts", "0001_initial"), + ("manage_subscriptions", "0001_initial"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="Transaction", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("active", models.BooleanField(default=True)), + ("deleted", models.BooleanField(default=False)), + ("created_on", models.DateTimeField(auto_now_add=True)), + ("modified_on", models.DateTimeField(auto_now=True)), + ( + "transaction_type", + models.CharField( + choices=[ + ("payment", "Payment"), + ("deposit", "Deposit"), + ("withdraw", "Withdraw"), + ], + max_length=10, + ), + ), + ( + "payment_method", + models.CharField( + choices=[("card", "Card"), ("upi", "UPI")], max_length=10 + ), + ), + ( + "transaction_status", + models.CharField( + choices=[ + ("success", "Success"), + ("fail", "Fail"), + ("initiate", "Initiate"), + ], + default="initiate", + max_length=10, + ), + ), + ( + "amount", + models.DecimalField(decimal_places=2, default=0.0, max_digits=14), + ), + ("comment", models.CharField(blank=True, max_length=200, null=True)), + ( + "order_id", + models.CharField( + blank=True, max_length=255, null=True, unique=True + ), + ), + ( + "product_id", + models.CharField( + blank=True, max_length=255, null=True, unique=True + ), + ), + ( + "reference_id", + models.CharField(blank=True, max_length=255, null=True), + ), + ( + "created_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="%(class)s_created", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "modified_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="%(class)s_modified", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "principal", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "principal_subscription", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="transaction_principal_subscription", + to="manage_subscriptions.principalsubscription", + ), + ), + ( + "principal_type", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="transaction_principal_type", + to="accounts.iamprincipaltype", + ), + ), + ], + options={ + "db_table": "transaction_history", + }, + ), + migrations.CreateModel( + name="Wallet", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("active", models.BooleanField(default=True)), + ("deleted", models.BooleanField(default=False)), + ("created_on", models.DateTimeField(auto_now_add=True)), + ("modified_on", models.DateTimeField(auto_now=True)), + ( + "balance", + models.DecimalField(decimal_places=2, default=0.0, max_digits=14), + ), + ( + "deposit", + models.DecimalField(decimal_places=2, default=0.0, max_digits=14), + ), + ( + "withdrawal_balance", + models.DecimalField(decimal_places=2, default=0.0, max_digits=14), + ), + ( + "created_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="%(class)s_created", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "modified_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="%(class)s_modified", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "principal", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "db_table": "wallet", + }, + ), + ] diff --git a/manage_wallets/migrations/__init__.py b/manage_wallets/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/manage_wallets/models.py b/manage_wallets/models.py new file mode 100644 index 0000000..8a81242 --- /dev/null +++ b/manage_wallets/models.py @@ -0,0 +1,88 @@ +from django.db import models +from accounts.models import BaseModel, IAmPrincipal, IAmPrincipalType +from django.db.models.signals import post_save +from django.dispatch import receiver +from manage_subscriptions.models import PrincipalSubscription + +# Create your models here. + + +class Wallet(BaseModel): + principal = models.OneToOneField(IAmPrincipal, on_delete=models.CASCADE) + balance = models.DecimalField(max_digits=14, decimal_places=2, default=0.00) + deposit = models.DecimalField(max_digits=14, decimal_places=2, default=0.00) + withdrawal_balance = models.DecimalField( + max_digits=14, decimal_places=2, default=0.00 + ) + + class Meta: + db_table = "wallet" + + def __str__(self): + return f"Balance: {self.balance} For Principal: {self.principal}" + + +@receiver(post_save, sender=IAmPrincipal) +def create_wallet(sender, instance, created, **kwargs): + if created: + wallet = Wallet.objects.create(principal=instance) + + +class TransactionType(models.TextChoices): + PAYMENT = "payment", "Payment" + DEPOSIT = "deposit", "Deposit" + WITHDRAW = "withdraw", "Withdraw" + + +class TransactionStatus(models.TextChoices): + SUCCESS = "success", "Success" + FAIL = "fail", "Fail" + INITIATE = "initiate", "Initiate" + + +class PaymentMethod(models.TextChoices): + CARD = "card", "Card" + UPI = "upi", "UPI" + + +class Transaction(BaseModel): + principal = models.ForeignKey(IAmPrincipal, on_delete=models.CASCADE) + principal_type = models.ForeignKey( + IAmPrincipalType, + related_name="transaction_principal_type", + on_delete=models.CASCADE, + ) + principal_subscription = models.ForeignKey( + PrincipalSubscription, + related_name="transaction_principal_subscription", + on_delete=models.CASCADE, + ) + transaction_type = models.CharField( + max_length=10, + choices=TransactionType.choices, + ) + payment_method = models.CharField( + max_length=10, + choices=PaymentMethod.choices, + ) + transaction_status = models.CharField( + max_length=10, + choices=TransactionStatus.choices, + default=TransactionStatus.INITIATE, + ) + amount = models.DecimalField(max_digits=14, decimal_places=2, default=0.00) + comment = models.CharField(max_length=200, null=True, blank=True) + order_id = models.CharField(unique=True, max_length=255, null=True, blank=True) + product_id = models.CharField(unique=True, max_length=255, null=True, blank=True) + reference_id = models.CharField(max_length=255, null=True, blank=True) + + def save(self, *args, **kwargs): + if self.order_id is None and self.created_on and self.id: + self.order_id = self.created_on.strftime("GDTMS%Y%m%dODR") + str(self.id) + return super().save(*args, **kwargs) + + class Meta: + db_table = "transaction_history" + + def __str__(self): + return f"principal: {self.principal}, type: {self.transaction_type}, status: {self.transaction_status}, amount: {self.amount}" diff --git a/manage_wallets/tests.py b/manage_wallets/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/manage_wallets/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/manage_wallets/urls.py b/manage_wallets/urls.py new file mode 100644 index 0000000..6b7780f --- /dev/null +++ b/manage_wallets/urls.py @@ -0,0 +1,16 @@ +from django.urls import path +from . import views + +app_name = "manage_wallets" + +urlpatterns = [ + + # for manage wallet related url + path('wallet/', views.WalletListView.as_view(), name='wallet_list'), + + + + # for manage payment related url + path('peyment/', views.PaymentListView.as_view(), name='payment_list'), + +] diff --git a/manage_wallets/views.py b/manage_wallets/views.py new file mode 100644 index 0000000..9f42dda --- /dev/null +++ b/manage_wallets/views.py @@ -0,0 +1,34 @@ +from django.shortcuts import render +from django.contrib.auth.mixins import LoginRequiredMixin +from accounts import resource_action +from django.views import generic +from .models import Wallet, Transaction + +"""Wallet Related VIew""" + + +class WalletListView(LoginRequiredMixin, generic.TemplateView): + page_name = resource_action.RESOURCE_MANAGE_WALLET + template_name = "manage_wallets/wallet_list.html" + model = Wallet + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["page_name"] = self.page_name + context["wallet_objs"] = Wallet.objects.filter(deleted=False) + return context + + +"""Payment Related View""" + + +class PaymentListView(LoginRequiredMixin, generic.TemplateView): + page_name = resource_action.RESOURCE_MANAGE_PAYMENT + template_name = "manage_wallets/payment_list.html" + model = Transaction + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["page_name"] = self.page_name + context["transaction_objs"] = Transaction.objects.filter(deleted=False) + return context diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..a17ee49 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,54 @@ +asgiref==3.7.2 +attrs==23.2.0 +autobahn==23.6.2 +Automat==22.10.0 +certifi==2024.2.2 +cffi==1.16.0 +charset-normalizer==3.3.2 +colorama==0.4.6 +colorlog==6.8.2 +constantly==23.10.4 +cryptography==42.0.2 +daphne==4.1.0 +defusedxml==0.7.1 +Django==5.0.2 +django-allauth==0.61.1 +django-channels==0.7.0 +django-cors-headers==4.3.1 +django-debug-toolbar==4.3.0 +django-environ==0.11.2 +django-extensions==3.2.3 +django-phonenumber-field==7.3.0 +django-quill-editor==0.1.40 +django-taggit==5.0.1 +django-widget-tweaks==1.5.0 +djangorestframework==3.14.0 +djangorestframework-simplejwt==5.3.1 +hyperlink==21.0.0 +idna==3.6 +incremental==22.10.0 +mysqlclient==2.2.4 +oauthlib==3.2.2 +phonenumbers==8.13.30 +pillow==10.2.0 +pyasn1==0.5.1 +pyasn1-modules==0.3.0 +pycparser==2.21 +PyJWT==2.8.0 +pyOpenSSL==24.0.0 +python3-openid==3.2.0 +pytz==2024.1 +requests==2.31.0 +requests-oauthlib==1.3.1 +service-identity==24.1.0 +six==1.16.0 +sqlparse==0.4.4 +stripe==8.2.0 +tqdm==4.66.2 +Twisted==23.10.0 +twisted-iocpsupport==1.0.4 +txaio==23.1.1 +typing_extensions==4.9.0 +tzdata==2024.1 +urllib3==2.2.0 +zope.interface==6.1 diff --git a/static/admin/css/autocomplete.css b/static/admin/css/autocomplete.css new file mode 100644 index 0000000..69c94e7 --- /dev/null +++ b/static/admin/css/autocomplete.css @@ -0,0 +1,275 @@ +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: var(--body-quiet-color); + 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: var(--body-bg); + border: 1px solid var(--border-color); + border-radius: 4px; +} + +.select2-container--admin-autocomplete .select2-selection--single .select2-selection__rendered { + color: var(--body-fg); + 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: var(--body-quiet-color); +} + +.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: var(--darkened-bg); + 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: var(--body-bg); + border: 1px solid var(--border-color); + 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 10px 5px 5px; + width: 100%; + display: flex; + flex-wrap: wrap; +} + +.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: var(--body-quiet-color); + margin-top: 5px; + float: left; +} + +.select2-container--admin-autocomplete .select2-selection--multiple .select2-selection__clear { + cursor: pointer; + float: right; + font-weight: bold; + margin: 5px; + position: absolute; + right: 0; +} + +.select2-container--admin-autocomplete .select2-selection--multiple .select2-selection__choice { + background-color: var(--darkened-bg); + border: 1px solid var(--border-color); + 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: var(--body-quiet-color); + cursor: pointer; + display: inline-block; + font-weight: bold; + margin-right: 2px; +} + +.select2-container--admin-autocomplete .select2-selection--multiple .select2-selection__choice__remove:hover { + color: var(--body-fg); +} + +.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 var(--body-quiet-color) 1px; + outline: 0; +} + +.select2-container--admin-autocomplete.select2-container--disabled .select2-selection--multiple { + background-color: var(--darkened-bg); + 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 { + background: var(--darkened-bg); +} + +.select2-container--admin-autocomplete .select2-search--dropdown .select2-search__field { + background: var(--body-bg); + color: var(--body-fg); + border: 1px solid var(--border-color); + border-radius: 4px; +} + +.select2-container--admin-autocomplete .select2-search--inline .select2-search__field { + background: transparent; + color: var(--body-fg); + 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; + color: var(--body-fg); + background: var(--body-bg); +} + +.select2-container--admin-autocomplete .select2-results__option[role=group] { + padding: 0; +} + +.select2-container--admin-autocomplete .select2-results__option[aria-disabled=true] { + color: var(--body-quiet-color); +} + +.select2-container--admin-autocomplete .select2-results__option[aria-selected=true] { + background-color: var(--selected-bg); + color: var(--body-fg); +} + +.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: var(--primary); + color: var(--primary-fg); +} + +.select2-container--admin-autocomplete .select2-results__group { + cursor: default; + display: block; + padding: 6px; +} diff --git a/static/admin/css/base.css b/static/admin/css/base.css new file mode 100644 index 0000000..72f4ae1 --- /dev/null +++ b/static/admin/css/base.css @@ -0,0 +1,1138 @@ +/* + DJANGO Admin styles +*/ + +/* VARIABLE DEFINITIONS */ +html[data-theme="light"], +:root { + --primary: #79aec8; + --secondary: #417690; + --accent: #f5dd5d; + --primary-fg: #fff; + + --body-fg: #333; + --body-bg: #fff; + --body-quiet-color: #666; + --body-loud-color: #000; + + --header-color: #ffc; + --header-branding-color: var(--accent); + --header-bg: var(--secondary); + --header-link-color: var(--primary-fg); + + --breadcrumbs-fg: #c4dce8; + --breadcrumbs-link-fg: var(--body-bg); + --breadcrumbs-bg: var(--primary); + + --link-fg: #417893; + --link-hover-color: #036; + --link-selected-fg: #5b80b2; + + --hairline-color: #e8e8e8; + --border-color: #ccc; + + --error-fg: #ba2121; + + --message-success-bg: #dfd; + --message-warning-bg: #ffc; + --message-error-bg: #ffefef; + + --darkened-bg: #f8f8f8; /* A bit darker than --body-bg */ + --selected-bg: #e4e4e4; /* E.g. selected table cells */ + --selected-row: #ffc; + + --button-fg: #fff; + --button-bg: var(--primary); + --button-hover-bg: #609ab6; + --default-button-bg: var(--secondary); + --default-button-hover-bg: #205067; + --close-button-bg: #747474; + --close-button-hover-bg: #333; + --delete-button-bg: #ba2121; + --delete-button-hover-bg: #a41515; + + --object-tools-fg: var(--button-fg); + --object-tools-bg: var(--close-button-bg); + --object-tools-hover-bg: var(--close-button-hover-bg); + + --font-family-primary: + -apple-system, + BlinkMacSystemFont, + "Segoe UI", + system-ui, + Roboto, + "Helvetica Neue", + Arial, + sans-serif, + "Apple Color Emoji", + "Segoe UI Emoji", + "Segoe UI Symbol", + "Noto Color Emoji"; + --font-family-monospace: + ui-monospace, + Menlo, + Monaco, + "Cascadia Mono", + "Segoe UI Mono", + "Roboto Mono", + "Oxygen Mono", + "Ubuntu Monospace", + "Source Code Pro", + "Fira Mono", + "Droid Sans Mono", + "Courier New", + monospace, + "Apple Color Emoji", + "Segoe UI Emoji", + "Segoe UI Symbol", + "Noto Color Emoji"; +} + +html, body { + height: 100%; +} + +body { + margin: 0; + padding: 0; + font-size: 0.875rem; + font-family: var(--font-family-primary); + color: var(--body-fg); + background: var(--body-bg); +} + +/* LINKS */ + +a:link, a:visited { + color: var(--link-fg); + text-decoration: none; + transition: color 0.15s, background 0.15s; +} + +a:focus, a:hover { + color: var(--link-hover-color); +} + +a:focus { + text-decoration: underline; +} + +a img { + border: none; +} + +a.section:link, a.section:visited { + color: var(--header-link-color); + 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: 1.25rem; + color: var(--body-quiet-color); +} + +h2 { + font-size: 1rem; + margin: 1em 0 .5em 0; +} + +h2.subhead { + font-weight: normal; + margin-top: 0; +} + +h3 { + font-size: 0.875rem; + margin: .8em 0 .3em 0; + color: var(--body-quiet-color); + font-weight: bold; +} + +h4 { + font-size: 0.75rem; + margin: 1em 0 .8em 0; + padding-bottom: 3px; +} + +h5 { + font-size: 0.625rem; + margin: 1.5em 0 .5em 0; + color: var(--body-quiet-color); + 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: 0.8125rem; + line-height: 1.25rem; +} + +dt { + font-weight: bold; + margin-top: 4px; +} + +dd { + margin-left: 0; +} + +form { + margin: 0; + padding: 0; +} + +fieldset { + margin: 0; + min-width: 0; + padding: 0; + border: none; + border-top: 1px solid var(--hairline-color); +} + +blockquote { + font-size: 0.6875rem; + color: #777; + margin-left: 2px; + padding-left: 10px; + border-left: 5px solid #ddd; +} + +code, pre { + font-family: var(--font-family-monospace); + color: var(--body-quiet-color); + font-size: 0.75rem; + overflow-x: auto; +} + +pre.literal-block { + margin: 10px; + background: var(--darkened-bg); + padding: 6px 8px; +} + +code strong { + color: #930; +} + +hr { + clear: both; + color: var(--hairline-color); + background-color: var(--hairline-color); + height: 1px; + border: none; + margin: 0; + padding: 0; + line-height: 1px; +} + +/* TEXT STYLES & MODIFIERS */ + +.small { + font-size: 0.6875rem; +} + +.mini { + font-size: 0.625rem; +} + +.help, p.help, form p.help, div.help, form div.help, div.help li { + font-size: 0.6875rem; + color: var(--body-quiet-color); +} + +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: var(--body-quiet-color); + font-weight: normal; +} + +.clear { + clear: both; +} + +.nowrap { + white-space: nowrap; +} + +.hidden { + display: none !important; +} + +/* TABLES */ + +table { + border-collapse: collapse; + border-color: var(--border-color); +} + +td, th { + font-size: 0.8125rem; + line-height: 1rem; + border-bottom: 1px solid var(--hairline-color); + vertical-align: top; + padding: 8px; +} + +th { + font-weight: 600; + text-align: left; +} + +thead th, +tfoot td { + color: var(--body-quiet-color); + padding: 5px 10px; + font-size: 0.6875rem; + background: var(--body-bg); + border: none; + border-top: 1px solid var(--hairline-color); + border-bottom: 1px solid var(--hairline-color); +} + +tfoot td { + border-bottom: none; + border-top: 1px solid var(--hairline-color); +} + +thead th.required { + color: var(--body-loud-color); +} + +tr.alt { + background: var(--darkened-bg); +} + +tr:nth-child(odd), .row-form-errors { + background: var(--body-bg); +} + +tr:nth-child(even), +tr:nth-child(even) .errorlist, +tr:nth-child(odd) + .row-form-errors, +tr:nth-child(odd) + .row-form-errors .errorlist { + background: var(--darkened-bg); +} + +/* SORTABLE TABLES */ + +thead th { + padding: 5px 10px; + line-height: normal; + text-transform: uppercase; + background: var(--darkened-bg); +} + +thead th a:link, thead th a:visited { + color: var(--body-quiet-color); +} + +thead th.sorted { + background: var(--selected-bg); +} + +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: var(--selected-bg); +} + +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: 1.125rem; + color: var(--body-quiet-color); +} + +table thead th.sorted .sortoptions a.sortremove:focus:after, +table thead th.sorted .sortoptions a.sortremove:hover:after { + color: var(--link-fg); +} + +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: var(--font-family-primary); + font-weight: normal; + font-size: 0.8125rem; +} +.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 var(--border-color); + border-radius: 4px; + padding: 5px 6px; + margin-top: 0; + color: var(--body-fg); + background-color: var(--body-bg); +} + +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: var(--body-quiet-color); +} + +select { + height: 1.875rem; +} + +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: var(--button-bg); + padding: 10px 15px; + border: none; + border-radius: 4px; + color: var(--button-fg); + cursor: pointer; + transition: background 0.15s; +} + +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: var(--button-hover-bg); +} + +.button[disabled], input[type=submit][disabled], input[type=button][disabled] { + opacity: 0.4; +} + +.button.default, input[type=submit].default, .submit-row input.default { + border: none; + font-weight: 400; + background: var(--default-button-bg); +} + +.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: var(--default-button-hover-bg); +} + +.button[disabled].default, +input[type=submit][disabled].default, +input[type=button][disabled].default { + opacity: 0.4; +} + + +/* MODULES */ + +.module { + border: none; + margin-bottom: 30px; + background: var(--body-bg); +} + +.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: 0.8125rem; + text-align: left; + background: var(--primary); + color: var(--header-link-color); +} + +.module caption, +.inline-group h2 { + font-size: 0.75rem; + 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: 0.8125rem; + padding: 10px 10px 10px 65px; + margin: 0 0 10px 0; + background: var(--message-success-bg) url(../img/icon-yes.svg) 40px 12px no-repeat; + background-size: 16px auto; + color: var(--body-fg); + word-break: break-word; +} + +ul.messagelist li.warning { + background: var(--message-warning-bg) url(../img/icon-alert.svg) 40px 14px no-repeat; + background-size: 14px auto; +} + +ul.messagelist li.error { + background: var(--message-error-bg) url(../img/icon-no.svg) 40px 12px no-repeat; + background-size: 16px auto; +} + +.errornote { + font-size: 0.875rem; + font-weight: 700; + display: block; + padding: 10px 12px; + margin: 0 0 10px 0; + color: var(--error-fg); + border: 1px solid var(--error-fg); + border-radius: 4px; + background-color: var(--body-bg); + background-position: 5px 12px; + overflow-wrap: break-word; +} + +ul.errorlist { + margin: 0 0 4px; + padding: 0; + color: var(--error-fg); + background: var(--body-bg); +} + +ul.errorlist li { + font-size: 0.8125rem; + display: block; + margin-bottom: 4px; + overflow-wrap: break-word; +} + +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 var(--hairline-color); + background: none; +} + +.form-row.errors ul.errorlist li { + padding-left: 0; +} + +.errors input, .errors select, .errors textarea, +td ul.errorlist + input, td ul.errorlist + select, td ul.errorlist + textarea { + border: 1px solid var(--error-fg); +} + +.description { + font-size: 0.75rem; + padding: 5px 0 0 12px; +} + +/* BREADCRUMBS */ + +div.breadcrumbs { + background: var(--breadcrumbs-bg); + padding: 10px 40px; + border: none; + color: var(--breadcrumbs-fg); + text-align: left; +} + +div.breadcrumbs a { + color: var(--breadcrumbs-link-fg); +} + +div.breadcrumbs a:focus, div.breadcrumbs a:hover { + color: var(--breadcrumbs-fg); +} + +/* 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; /* XXX Probably unused? */ +} + +a.deletelink:focus, a.deletelink:hover { + color: #993333; /* XXX Probably unused? */ + text-decoration: none; +} + +/* OBJECT TOOLS */ + +.object-tools { + font-size: 0.625rem; + font-weight: bold; + padding-left: 0; + float: right; + position: relative; + margin-top: -48px; +} + +.object-tools li { + display: block; + float: left; + margin-left: 5px; + height: 1rem; +} + +.object-tools a { + border-radius: 15px; +} + +.object-tools a:link, .object-tools a:visited { + display: block; + float: left; + padding: 3px 12px; + background: var(--object-tools-bg); + color: var(--object-tools-fg); + font-weight: 400; + font-size: 0.6875rem; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.object-tools a:focus, .object-tools a:hover { + background-color: var(--object-tools-hover-bg); +} + +.object-tools a:focus{ + text-decoration: none; +} + +.object-tools a.viewsitelink, .object-tools a.addlink { + background-repeat: no-repeat; + background-position: right 7px center; + padding-right: 26px; +} + +.object-tools a.viewsitelink { + background-image: url(../img/tooltag-arrowright.svg); +} + +.object-tools a.addlink { + background-image: url(../img/tooltag-add.svg); +} + +/* OBJECT HISTORY */ + +#change-history table { + width: 100%; +} + +#change-history table tbody th { + width: 16em; +} + +#change-history .paginator { + color: var(--body-quiet-color); + border-bottom: 1px solid var(--hairline-color); + background: var(--body-bg); + overflow: hidden; +} + +/* PAGE STRUCTURE */ + +#container { + position: relative; + width: 100%; + min-width: 980px; + padding: 0; + display: flex; + flex-direction: column; + height: 100%; +} + +#container > div { + flex-shrink: 0; +} + +#container > .main { + display: flex; + flex: 1 0 auto; +} + +.main > .content { + flex: 1 0; + max-width: 100%; +} + +.skip-to-content-link { + position: absolute; + top: -999px; + margin: 5px; + padding: 5px; + background: var(--body-bg); + z-index: 1; +} + +.skip-to-content-link:focus { + left: 0px; + top: 0px; +} + +#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: var(--header-bg); + color: var(--header-color); + overflow: hidden; +} + +#header a:link, #header a:visited, #logout-form button { + color: var(--header-link-color); +} + +#header a:focus , #header a:hover { + text-decoration: underline; +} + +#branding { + display: flex; +} + +#branding h1 { + padding: 0; + margin: 0; + margin-inline-end: 20px; + font-weight: 300; + font-size: 1.5rem; + color: var(--header-branding-color); +} + +#branding h1 a:link, #branding h1 a:visited { + color: var(--accent); +} + +#branding h2 { + padding: 0 10px; + font-size: 0.875rem; + margin: -8px 0 8px 0; + font-weight: normal; + color: var(--header-color); +} + +#branding a:hover { + text-decoration: none; +} + +#logout-form { + display: inline; +} + +#logout-form button { + background: none; + border: 0; + cursor: pointer; + font-family: var(--font-family-primary); +} + +#user-tools { + float: right; + margin: 0 0 0 20px; + text-align: right; +} + +#user-tools, #logout-form button{ + padding: 0; + font-weight: 300; + font-size: 0.6875rem; + letter-spacing: 0.5px; + text-transform: uppercase; +} + +#user-tools a, #logout-form button { + border-bottom: 1px solid rgba(255, 255, 255, 0.25); +} + +#user-tools a:focus, #user-tools a:hover, +#logout-form button:active, #logout-form button:hover { + text-decoration: none; + border-bottom: 0; +} + +#logout-form button:active, #logout-form button:hover { + margin-bottom: 1px; +} + +/* SIDEBAR */ + +#content-related { + background: var(--darkened-bg); +} + +#content-related .module { + background: none; +} + +#content-related h3 { + color: var(--body-quiet-color); + padding: 0 16px; + margin: 0 0 16px; +} + +#content-related h4 { + font-size: 0.8125rem; +} + +#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 var(--hairline-color); + font-size: 1.125rem; + color: var(--body-fg); +} + +.delete-confirmation form input[type="submit"] { + background: var(--delete-button-bg); + border-radius: 4px; + padding: 10px 15px; + color: var(--button-fg); +} + +.delete-confirmation form input[type="submit"]:active, +.delete-confirmation form input[type="submit"]:focus, +.delete-confirmation form input[type="submit"]:hover { + background: var(--delete-button-hover-bg); +} + +.delete-confirmation form .cancel-link { + display: inline-block; + vertical-align: middle; + height: 0.9375rem; + line-height: 0.9375rem; + border-radius: 4px; + padding: 10px 15px; + color: var(--button-fg); + background: var(--close-button-bg); + margin: 0 0 0 10px; +} + +.delete-confirmation form .cancel-link:active, +.delete-confirmation form .cancel-link:focus, +.delete-confirmation form .cancel-link:hover { + background: var(--close-button-hover-bg); +} + +/* POPUP */ +.popup #content { + padding: 20px; +} + +.popup #container { + min-width: 0; +} + +.popup #header { + padding: 10px 20px; +} + +/* PAGINATOR */ + +.paginator { + font-size: 0.8125rem; + padding-top: 10px; + padding-bottom: 10px; + line-height: 22px; + margin: 0; + border-top: 1px solid var(--hairline-color); + width: 100%; +} + +.paginator a:link, .paginator a:visited { + padding: 2px 6px; + background: var(--button-bg); + text-decoration: none; + color: var(--button-fg); +} + +.paginator a.showall { + border: none; + background: none; + color: var(--link-fg); +} + +.paginator a.showall:focus, .paginator a.showall:hover { + background: none; + color: var(--link-hover-color); +} + +.paginator .end { + margin-right: 6px; +} + +.paginator .this-page { + padding: 2px 6px; + font-weight: bold; + font-size: 0.8125rem; + vertical-align: top; +} + +.paginator a:focus, .paginator a:hover { + color: white; + background: var(--link-hover-color); +} + +.base-svgs { + display: none; +} diff --git a/static/admin/css/changelists.css b/static/admin/css/changelists.css new file mode 100644 index 0000000..a754513 --- /dev/null +++ b/static/admin/css/changelists.css @@ -0,0 +1,328 @@ +/* CHANGELISTS */ + +#changelist { + display: flex; + align-items: flex-start; + justify-content: space-between; +} + +#changelist .changelist-form-container { + flex: 1 1 auto; + min-width: 0; +} + +#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 { + width: auto; +} + +.change-list .filtered table tbody th { + padding-right: 1em; +} + +#changelist-form .results { + overflow-x: auto; + width: 100%; +} + +#changelist .toplinks { + border-bottom: 1px solid var(--hairline-color); +} + +#changelist .paginator { + color: var(--body-quiet-color); + border-bottom: 1px solid var(--hairline-color); + background: var(--body-bg); + 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: var(--body-quiet-color); +} + +/* TOOLBAR */ + +#toolbar { + padding: 8px 10px; + margin-bottom: 15px; + border-top: 1px solid var(--hairline-color); + border-bottom: 1px solid var(--hairline-color); + background: var(--darkened-bg); + color: var(--body-quiet-color); +} + +#toolbar form input { + border-radius: 4px; + font-size: 0.875rem; + padding: 5px; + color: var(--body-fg); +} + +#toolbar #searchbar { + height: 1.1875rem; + border: 1px solid var(--border-color); + padding: 2px 5px; + margin: 0; + vertical-align: top; + font-size: 0.8125rem; + max-width: 100%; +} + +#toolbar #searchbar:focus { + border-color: var(--body-quiet-color); +} + +#toolbar form input[type="submit"] { + border: 1px solid var(--border-color); + font-size: 0.8125rem; + padding: 4px 8px; + margin: 0; + vertical-align: middle; + background: var(--body-bg); + box-shadow: 0 -15px 20px -10px rgba(0, 0, 0, 0.15) inset; + cursor: pointer; + color: var(--body-fg); +} + +#toolbar form input[type="submit"]:focus, +#toolbar form input[type="submit"]:hover { + border-color: var(--body-quiet-color); +} + +#changelist-search img { + vertical-align: middle; + margin-right: 4px; +} + +#changelist-search .help { + word-break: break-word; +} + +/* FILTER COLUMN */ + +#changelist-filter { + flex: 0 0 240px; + order: 1; + background: var(--darkened-bg); + border-left: none; + margin: 0 0 0 30px; +} + +#changelist-filter h2 { + font-size: 0.875rem; + text-transform: uppercase; + letter-spacing: 0.5px; + padding: 5px 15px; + margin-bottom: 12px; + border-bottom: none; +} + +#changelist-filter h3, +#changelist-filter details summary { + font-weight: 400; + padding: 0 15px; + margin-bottom: 10px; +} + +#changelist-filter details summary > * { + display: inline; +} + +#changelist-filter details > summary { + list-style-type: none; +} + +#changelist-filter details > summary::-webkit-details-marker { + display: none; +} + +#changelist-filter details > summary::before { + content: '→'; + font-weight: bold; + color: var(--link-hover-color); +} + +#changelist-filter details[open] > summary::before { + content: '↓'; +} + +#changelist-filter ul { + margin: 5px 0; + padding: 0 15px 15px; + border-bottom: 1px solid var(--hairline-color); +} + +#changelist-filter ul:last-child { + border-bottom: none; +} + +#changelist-filter li { + list-style-type: none; + margin-left: 0; + padding-left: 0; +} + +#changelist-filter a { + display: block; + color: var(--body-quiet-color); + word-break: break-word; +} + +#changelist-filter li.selected { + border-left: 5px solid var(--hairline-color); + padding-left: 10px; + margin-left: -15px; +} + +#changelist-filter li.selected a { + color: var(--link-selected-fg); +} + +#changelist-filter a:focus, #changelist-filter a:hover, +#changelist-filter li.selected a:focus, +#changelist-filter li.selected a:hover { + color: var(--link-hover-color); +} + +#changelist-filter #changelist-filter-clear a { + font-size: 0.8125rem; + padding-bottom: 10px; + border-bottom: 1px solid var(--hairline-color); +} + +/* DATE DRILLDOWN */ + +.change-list .toplinks { + display: flex; + padding-bottom: 5px; + flex-wrap: wrap; + gap: 3px 17px; + font-weight: bold; +} + +.change-list .toplinks a { + font-size: 0.8125rem; +} + +.change-list .toplinks .date-back { + color: var(--body-quiet-color); +} + +.change-list .toplinks .date-back:focus, +.change-list .toplinks .date-back:hover { + color: var(--link-hover-color); +} + +/* ACTIONS */ + +.filtered .actions { + border-right: none; +} + +#changelist table input { + margin: 0; + vertical-align: baseline; +} + +/* Once the :has() pseudo-class is supported by all browsers, the tr.selected + selector and the JS adding the class can be removed. */ +#changelist tbody tr.selected { + background-color: var(--selected-row); +} + +#changelist tbody tr:has(.action-select:checked) { + background-color: var(--selected-row); +} + +#changelist .actions { + padding: 10px; + background: var(--body-bg); + border-top: none; + border-bottom: none; + line-height: 1.5rem; + color: var(--body-quiet-color); + width: 100%; +} + +#changelist .actions span.all, +#changelist .actions span.action-counter, +#changelist .actions span.clear, +#changelist .actions span.question { + font-size: 0.8125rem; + margin: 0 0.5em; +} + +#changelist .actions:last-child { + border-bottom: none; +} + +#changelist .actions select { + vertical-align: top; + height: 1.5rem; + color: var(--body-fg); + border: 1px solid var(--border-color); + border-radius: 4px; + font-size: 0.875rem; + padding: 0 0 0 4px; + margin: 0; + margin-left: 10px; +} + +#changelist .actions select:focus { + border-color: var(--body-quiet-color); +} + +#changelist .actions label { + display: inline-block; + vertical-align: middle; + font-size: 0.8125rem; +} + +#changelist .actions .button { + font-size: 0.8125rem; + border: 1px solid var(--border-color); + border-radius: 4px; + background: var(--body-bg); + box-shadow: 0 -15px 20px -10px rgba(0, 0, 0, 0.15) inset; + cursor: pointer; + height: 1.5rem; + line-height: 1; + padding: 4px 8px; + margin: 0; + color: var(--body-fg); +} + +#changelist .actions .button:focus, #changelist .actions .button:hover { + border-color: var(--body-quiet-color); +} diff --git a/static/admin/css/dark_mode.css b/static/admin/css/dark_mode.css new file mode 100644 index 0000000..6d08233 --- /dev/null +++ b/static/admin/css/dark_mode.css @@ -0,0 +1,137 @@ +@media (prefers-color-scheme: dark) { + :root { + --primary: #264b5d; + --primary-fg: #f7f7f7; + + --body-fg: #eeeeee; + --body-bg: #121212; + --body-quiet-color: #e0e0e0; + --body-loud-color: #ffffff; + + --breadcrumbs-link-fg: #e0e0e0; + --breadcrumbs-bg: var(--primary); + + --link-fg: #81d4fa; + --link-hover-color: #4ac1f7; + --link-selected-fg: #6f94c6; + + --hairline-color: #272727; + --border-color: #353535; + + --error-fg: #e35f5f; + --message-success-bg: #006b1b; + --message-warning-bg: #583305; + --message-error-bg: #570808; + + --darkened-bg: #212121; + --selected-bg: #1b1b1b; + --selected-row: #00363a; + + --close-button-bg: #333333; + --close-button-hover-bg: #666666; + } + } + + +html[data-theme="dark"] { + --primary: #264b5d; + --primary-fg: #f7f7f7; + + --body-fg: #eeeeee; + --body-bg: #121212; + --body-quiet-color: #e0e0e0; + --body-loud-color: #ffffff; + + --breadcrumbs-link-fg: #e0e0e0; + --breadcrumbs-bg: var(--primary); + + --link-fg: #81d4fa; + --link-hover-color: #4ac1f7; + --link-selected-fg: #6f94c6; + + --hairline-color: #272727; + --border-color: #353535; + + --error-fg: #e35f5f; + --message-success-bg: #006b1b; + --message-warning-bg: #583305; + --message-error-bg: #570808; + + --darkened-bg: #212121; + --selected-bg: #1b1b1b; + --selected-row: #00363a; + + --close-button-bg: #333333; + --close-button-hover-bg: #666666; +} + +/* THEME SWITCH */ +.theme-toggle { + cursor: pointer; + border: none; + padding: 0; + background: transparent; + vertical-align: middle; + margin-inline-start: 5px; + margin-top: -1px; +} + +.theme-toggle svg { + vertical-align: middle; + height: 1rem; + width: 1rem; + display: none; +} + +/* +Fully hide screen reader text so we only show the one matching the current +theme. +*/ +.theme-toggle .visually-hidden { + display: none; +} + +html[data-theme="auto"] .theme-toggle .theme-label-when-auto { + display: block; +} + +html[data-theme="dark"] .theme-toggle .theme-label-when-dark { + display: block; +} + +html[data-theme="light"] .theme-toggle .theme-label-when-light { + display: block; +} + +/* ICONS */ +.theme-toggle svg.theme-icon-when-auto, +.theme-toggle svg.theme-icon-when-dark, +.theme-toggle svg.theme-icon-when-light { + fill: var(--header-link-color); + color: var(--header-bg); +} + +html[data-theme="auto"] .theme-toggle svg.theme-icon-when-auto { + display: block; +} + +html[data-theme="dark"] .theme-toggle svg.theme-icon-when-dark { + display: block; +} + +html[data-theme="light"] .theme-toggle svg.theme-icon-when-light { + display: block; +} + +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + overflow: hidden; + clip: rect(0,0,0,0); + white-space: nowrap; + border: 0; + color: var(--body-fg); + background-color: var(--body-bg); +} diff --git a/static/admin/css/dashboard.css b/static/admin/css/dashboard.css new file mode 100644 index 0000000..242b81a --- /dev/null +++ b/static/admin/css/dashboard.css @@ -0,0 +1,29 @@ +/* DASHBOARD */ +.dashboard td, .dashboard th { + word-break: break-word; +} + +.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; +} diff --git a/static/admin/css/forms.css b/static/admin/css/forms.css new file mode 100644 index 0000000..e45abe1 --- /dev/null +++ b/static/admin/css/forms.css @@ -0,0 +1,530 @@ +@import url('widgets.css'); + +/* FORM ROWS */ + +.form-row { + overflow: hidden; + padding: 10px; + font-size: 0.8125rem; + border-bottom: 1px solid var(--hairline-color); +} + +.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; +} + +.flex-container { + display: flex; + flex-wrap: wrap; +} + +.form-multiline > div { + padding-bottom: 10px; +} + +/* FORM LABELS */ + +label { + font-weight: normal; + color: var(--body-quiet-color); + font-size: 0.8125rem; +} + +.required label, label.required { + font-weight: bold; + color: var(--body-fg); +} + +/* RADIO BUTTONS */ + +form div.radiolist div { + padding-right: 7px; +} + +form div.radiolist.inline div { + display: inline-block; +} + +form div.radiolist label { + width: auto; +} + +form div.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; + width: 160px; + word-wrap: break-word; + line-height: 1; +} + +.aligned label:not(.vCheckboxLabel):after { + content: ''; + display: inline-block; + vertical-align: middle; + height: 1.625rem; +} + +.aligned label + p, .aligned .checkbox-row + div.help, .aligned label + div.readonly { + padding: 6px 0; + margin-top: 0; + margin-bottom: 0; + margin-left: 0; + overflow-wrap: break-word; +} + +.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 div.radiolist { + display: inline-block; + margin: 0; + padding: 0; +} + +form .aligned p.help, +form .aligned div.help { + margin-top: 0; + margin-left: 160px; + padding-left: 10px; +} + +form .aligned p.date div.help.timezonewarning, +form .aligned p.datetime div.help.timezonewarning, +form .aligned p.time div.help.timezonewarning { + margin-left: 0; + padding-left: 0; + font-weight: normal; +} + +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; +} + +fieldset .fieldBox { + margin-right: 20px; +} + +/* WIDE FIELDSETS */ + +.wide label { + width: 200px; +} + +form .wide p, +form .wide ul.errorlist, +form .wide input + p.help, +form .wide input + div.help { + margin-left: 200px; +} + +form .wide p.help, +form .wide div.help { + padding-left: 50px; +} + +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 var(--hairline-color); + border-radius: 4px; + overflow: hidden; +} + +fieldset.collapsed h2 { + background: var(--darkened-bg); + color: var(--body-quiet-color); +} + +fieldset .collapse-toggle { + color: var(--header-link-color); +} + +fieldset.collapsed .collapse-toggle { + background: transparent; + display: inline; + color: var(--link-fg); +} + +/* MONOSPACE TEXTAREAS */ + +fieldset.monospace textarea { + font-family: var(--font-family-monospace); +} + +/* SUBMIT ROW */ + +.submit-row { + padding: 12px 14px 12px; + margin: 0 0 20px; + background: var(--darkened-bg); + border: 1px solid var(--hairline-color); + border-radius: 4px; + overflow: hidden; + display: flex; + gap: 10px; + flex-wrap: wrap; +} + +body.popup .submit-row { + overflow: auto; +} + +.submit-row input { + height: 2.1875rem; + line-height: 0.9375rem; +} + +.submit-row input, .submit-row a { + margin: 0; +} + +.submit-row input.default { + text-transform: uppercase; +} + +.submit-row a.deletelink { + margin-left: auto; +} + +.submit-row a.deletelink { + display: block; + background: var(--delete-button-bg); + border-radius: 4px; + padding: 0.625rem 0.9375rem; + height: 0.9375rem; + line-height: 0.9375rem; + color: var(--button-fg); +} + +.submit-row a.closelink { + display: inline-block; + background: var(--close-button-bg); + border-radius: 4px; + padding: 10px 15px; + height: 0.9375rem; + line-height: 0.9375rem; + color: var(--button-fg); +} + +.submit-row a.deletelink:focus, +.submit-row a.deletelink:hover, +.submit-row a.deletelink:active { + background: var(--delete-button-hover-bg); + text-decoration: none; +} + +.submit-row a.closelink:focus, +.submit-row a.closelink:hover, +.submit-row a.closelink:active { + background: var(--close-button-hover-bg); + text-decoration: none; +} + +/* 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; +} + +.vIntegerField { + width: 5em; +} + +.vBigIntegerField { + width: 10em; +} + +.vForeignKeyRawIdAdminField { + width: 5em; +} + +.vTextField, .vUUIDField { + width: 20em; +} + +/* 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: var(--body-quiet-color); + padding: 5px; + font-size: 0.8125rem; + background: var(--darkened-bg); + border-top: 1px solid var(--hairline-color); + border-bottom: 1px solid var(--hairline-color); +} + +.inline-related h3 span.delete { + float: right; +} + +.inline-related h3 span.delete label { + margin-left: 2px; + font-size: 0.6875rem; +} + +.inline-related fieldset { + margin: 0; + background: var(--body-bg); + border: none; + width: 100%; +} + +.inline-related fieldset.module h3 { + margin: 0; + padding: 2px 5px 3px 5px; + font-size: 0.6875rem; + text-align: left; + font-weight: bold; + background: #bcd; + color: var(--body-bg); +} + +.inline-group .tabular fieldset.module { + border: none; +} + +.inline-related.tabular fieldset.module table { + width: 100%; + overflow-x: scroll; +} + +.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: 0.5625rem; + font-weight: bold; + color: var(--body-quiet-color); + _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: var(--body-quiet-color); + background: var(--darkened-bg); + padding: 8px 10px; + border-bottom: 1px solid var(--hairline-color); +} + +.inline-group .tabular tr.add-row td { + padding: 8px 10px; + border-bottom: 1px solid var(--hairline-color); +} + +.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: 0.75rem; +} + +.empty-form { + display: none; +} + +/* RELATED FIELD ADD ONE / LOOKUP */ + +.related-lookup { + margin-left: 5px; + display: inline-block; + vertical-align: middle; + background-repeat: no-repeat; + background-size: 14px; +} + +.related-lookup { + width: 1rem; + height: 1rem; + 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; +} diff --git a/static/admin/css/login.css b/static/admin/css/login.css new file mode 100644 index 0000000..389772f --- /dev/null +++ b/static/admin/css/login.css @@ -0,0 +1,61 @@ +/* LOGIN FORM */ + +.login { + background: var(--darkened-bg); + height: auto; +} + +.login #header { + height: auto; + padding: 15px 16px; + justify-content: center; +} + +.login #header h1 { + font-size: 1.125rem; + margin: 0; +} + +.login #header h1 a { + color: var(--header-link-color); +} + +.login #content { + padding: 20px 20px 0; +} + +.login #container { + background: var(--body-bg); + border: 1px solid var(--hairline-color); + border-radius: 4px; + overflow: hidden; + width: 28em; + min-width: 300px; + margin: 100px auto; + height: auto; +} + +.login .form-row { + padding: 4px 0; +} + +.login .form-row label { + display: block; + line-height: 2em; +} + +.login .form-row #id_username, .login .form-row #id_password { + padding: 8px; + width: 100%; + box-sizing: border-box; +} + +.login .submit-row { + padding: 1em 0 0 0; + margin: 0; + text-align: center; +} + +.login .password-reset-link { + text-align: center; +} diff --git a/static/admin/css/nav_sidebar.css b/static/admin/css/nav_sidebar.css new file mode 100644 index 0000000..f76e6ce --- /dev/null +++ b/static/admin/css/nav_sidebar.css @@ -0,0 +1,144 @@ +.sticky { + position: sticky; + top: 0; + max-height: 100vh; +} + +.toggle-nav-sidebar { + z-index: 20; + left: 0; + display: flex; + align-items: center; + justify-content: center; + flex: 0 0 23px; + width: 23px; + border: 0; + border-right: 1px solid var(--hairline-color); + background-color: var(--body-bg); + cursor: pointer; + font-size: 1.25rem; + color: var(--link-fg); + padding: 0; +} + +[dir="rtl"] .toggle-nav-sidebar { + border-left: 1px solid var(--hairline-color); + border-right: 0; +} + +.toggle-nav-sidebar:hover, +.toggle-nav-sidebar:focus { + background-color: var(--darkened-bg); +} + +#nav-sidebar { + z-index: 15; + flex: 0 0 275px; + left: -276px; + margin-left: -276px; + border-top: 1px solid transparent; + border-right: 1px solid var(--hairline-color); + background-color: var(--body-bg); + overflow: auto; +} + +[dir="rtl"] #nav-sidebar { + border-left: 1px solid var(--hairline-color); + border-right: 0; + left: 0; + margin-left: 0; + right: -276px; + margin-right: -276px; +} + +.toggle-nav-sidebar::before { + content: '\00BB'; +} + +.main.shifted .toggle-nav-sidebar::before { + content: '\00AB'; +} + +.main > #nav-sidebar { + visibility: hidden; +} + +.main.shifted > #nav-sidebar { + margin-left: 0; + visibility: visible; +} + +[dir="rtl"] .main.shifted > #nav-sidebar { + margin-right: 0; +} + +#nav-sidebar .module th { + width: 100%; + overflow-wrap: anywhere; +} + +#nav-sidebar .module th, +#nav-sidebar .module caption { + padding-left: 16px; +} + +#nav-sidebar .module td { + white-space: nowrap; +} + +[dir="rtl"] #nav-sidebar .module th, +[dir="rtl"] #nav-sidebar .module caption { + padding-left: 8px; + padding-right: 16px; +} + +#nav-sidebar .current-app .section:link, +#nav-sidebar .current-app .section:visited { + color: var(--header-color); + font-weight: bold; +} + +#nav-sidebar .current-model { + background: var(--selected-row); +} + +.main > #nav-sidebar + .content { + max-width: calc(100% - 23px); +} + +.main.shifted > #nav-sidebar + .content { + max-width: calc(100% - 299px); +} + +@media (max-width: 767px) { + #nav-sidebar, #toggle-nav-sidebar { + display: none; + } + + .main > #nav-sidebar + .content, + .main.shifted > #nav-sidebar + .content { + max-width: 100%; + } +} + +#nav-filter { + width: 100%; + box-sizing: border-box; + padding: 2px 5px; + margin: 5px 0; + border: 1px solid var(--border-color); + background-color: var(--darkened-bg); + color: var(--body-fg); +} + +#nav-filter:focus { + border-color: var(--body-quiet-color); +} + +#nav-filter.no-results { + background: var(--message-error-bg); +} + +#nav-sidebar table { + width: 100%; +} diff --git a/static/admin/css/responsive.css b/static/admin/css/responsive.css new file mode 100644 index 0000000..9ce4f67 --- /dev/null +++ b/static/admin/css/responsive.css @@ -0,0 +1,998 @@ +/* Tablets */ + +input[type="submit"], button { + -webkit-appearance: none; + appearance: none; +} + +@media (max-width: 1024px) { + /* Basic */ + + html { + -webkit-text-size-adjust: 100%; + } + + td, th { + padding: 10px; + font-size: 0.875rem; + } + + .small { + font-size: 0.75rem; + } + + /* Layout */ + + #container { + min-width: 0; + } + + #content { + padding: 15px 20px 20px; + } + + div.breadcrumbs { + padding: 10px 30px; + } + + /* Header */ + + #header { + flex-direction: column; + padding: 15px 30px; + justify-content: flex-start; + } + + #branding h1 { + margin: 0 0 8px; + line-height: 1.2; + } + + #user-tools { + margin: 0; + font-weight: 400; + line-height: 1.85; + text-align: left; + } + + #user-tools a { + display: inline-block; + line-height: 1.4; + } + + /* Dashboard */ + + .dashboard #content { + width: auto; + } + + #content-related { + margin-right: -290px; + } + + .colSM #content-related { + margin-left: -290px; + } + + .colMS { + margin-right: 290px; + } + + .colSM { + margin-left: 290px; + } + + .dashboard .module table td a { + padding-right: 0; + } + + td .changelink, td .addlink { + font-size: 0.8125rem; + } + + /* Changelist */ + + #toolbar { + border: none; + padding: 15px; + } + + #changelist-search > div { + display: flex; + flex-wrap: nowrap; + max-width: 480px; + } + + #changelist-search label { + line-height: 1.375rem; + } + + #toolbar form #searchbar { + flex: 1 0 auto; + width: 0; + height: 1.375rem; + margin: 0 10px 0 6px; + } + + #toolbar form input[type=submit] { + flex: 0 1 auto; + } + + #changelist-search .quiet { + width: 0; + flex: 1 0 auto; + margin: 5px 0 0 25px; + } + + #changelist .actions { + display: flex; + flex-wrap: wrap; + padding: 15px 0; + } + + #changelist .actions label { + display: flex; + } + + #changelist .actions select { + background: var(--body-bg); + } + + #changelist .actions .button { + min-width: 48px; + margin: 0 10px; + } + + #changelist .actions span.all, + #changelist .actions span.clear, + #changelist .actions span.question, + #changelist .actions span.action-counter { + font-size: 0.6875rem; + margin: 0 10px 0 0; + } + + #changelist-filter { + flex-basis: 200px; + } + + .change-list .filtered .results, + .change-list .filtered .paginator, + .filtered #toolbar, + .filtered .actions, + + #changelist .paginator { + border-top-color: var(--hairline-color); /* XXX Is this used at all? */ + } + + #changelist .results + .paginator { + border-top: none; + } + + /* Forms */ + + label { + font-size: 0.875rem; + } + + .form-row input[type=text], + .form-row input[type=password], + .form-row input[type=email], + .form-row input[type=url], + .form-row input[type=tel], + .form-row input[type=number], + .form-row textarea, + .form-row select, + .form-row .vTextField { + box-sizing: border-box; + margin: 0; + padding: 6px 8px; + min-height: 2.25rem; + font-size: 0.875rem; + } + + .form-row select { + height: 2.25rem; + } + + .form-row select[multiple] { + height: auto; + min-height: 0; + } + + fieldset .fieldBox + .fieldBox { + margin-top: 10px; + padding-top: 10px; + border-top: 1px solid var(--hairline-color); + } + + textarea { + max-width: 100%; + max-height: 120px; + } + + .aligned label { + padding-top: 6px; + } + + .aligned .related-lookup, + .aligned .datetimeshortcuts, + .aligned .related-lookup + strong { + align-self: center; + margin-left: 15px; + } + + form .aligned div.radiolist { + margin-left: 2px; + } + + .submit-row { + padding: 8px; + } + + .submit-row a.deletelink { + padding: 10px 7px; + } + + .button, input[type=submit], input[type=button], .submit-row input, a.button { + padding: 7px; + } + + /* Related widget */ + + .related-widget-wrapper { + float: none; + } + + .related-widget-wrapper-link + .selector { + max-width: calc(100% - 30px); + margin-right: 15px; + } + + select + .related-widget-wrapper-link, + .related-widget-wrapper-link + .related-widget-wrapper-link { + margin-left: 10px; + } + + /* Selector */ + + .selector { + display: flex; + width: 100%; + } + + .selector .selector-filter { + display: flex; + align-items: center; + } + + .selector .selector-filter label { + margin: 0 8px 0 0; + } + + .selector .selector-filter input { + width: auto; + min-height: 0; + flex: 1 1; + } + + .selector-available, .selector-chosen { + width: auto; + flex: 1 1; + display: flex; + flex-direction: column; + } + + .selector select { + width: 100%; + flex: 1 0 auto; + margin-bottom: 5px; + } + + .selector ul.selector-chooser { + width: 26px; + height: 52px; + padding: 2px 0; + margin: auto 15px; + border-radius: 20px; + transform: translateY(-10px); + } + + .selector-add, .selector-remove { + width: 20px; + height: 20px; + background-size: 20px auto; + } + + .selector-add { + background-position: 0 -120px; + } + + .selector-remove { + background-position: 0 -80px; + } + + a.selector-chooseall, a.selector-clearall { + align-self: center; + } + + .stacked { + flex-direction: column; + max-width: 480px; + } + + .stacked > * { + flex: 0 1 auto; + } + + .stacked select { + margin-bottom: 0; + } + + .stacked .selector-available, .stacked .selector-chosen { + width: auto; + } + + .stacked ul.selector-chooser { + width: 52px; + height: 26px; + padding: 0 2px; + margin: 15px auto; + transform: none; + } + + .stacked .selector-chooser li { + padding: 3px; + } + + .stacked .selector-add, .stacked .selector-remove { + background-size: 20px auto; + } + + .stacked .selector-add { + background-position: 0 -40px; + } + + .stacked .active.selector-add { + background-position: 0 -40px; + } + + .active.selector-add:focus, .active.selector-add:hover { + background-position: 0 -140px; + } + + .stacked .active.selector-add:focus, .stacked .active.selector-add:hover { + background-position: 0 -60px; + } + + .stacked .selector-remove { + background-position: 0 0; + } + + .stacked .active.selector-remove { + background-position: 0 0; + } + + .active.selector-remove:focus, .active.selector-remove:hover { + background-position: 0 -100px; + } + + .stacked .active.selector-remove:focus, .stacked .active.selector-remove:hover { + background-position: 0 -20px; + } + + .help-tooltip, .selector .help-icon { + display: none; + } + + .datetime input { + width: 50%; + max-width: 120px; + } + + .datetime span { + font-size: 0.8125rem; + } + + .datetime .timezonewarning { + display: block; + font-size: 0.6875rem; + color: var(--body-quiet-color); + } + + .datetimeshortcuts { + color: var(--border-color); /* XXX Redundant, .datetime span also sets #ccc */ + } + + .form-row .datetime input.vDateField, .form-row .datetime input.vTimeField { + width: 75%; + } + + .inline-group { + overflow: auto; + } + + /* Messages */ + + ul.messagelist li { + padding-left: 55px; + background-position: 30px 12px; + } + + ul.messagelist li.error { + background-position: 30px 12px; + } + + ul.messagelist li.warning { + background-position: 30px 14px; + } + + /* Login */ + + .login #header { + padding: 15px 20px; + } + + .login #branding h1 { + margin: 0; + } + + /* GIS */ + + div.olMap { + max-width: calc(100vw - 30px); + max-height: 300px; + } + + .olMap + .clear_features { + display: block; + margin-top: 10px; + } + + /* Docs */ + + .module table.xfull { + width: 100%; + } + + pre.literal-block { + overflow: auto; + } +} + +/* Mobile */ + +@media (max-width: 767px) { + /* Layout */ + + #header, #content, #footer { + padding: 15px; + } + + #footer:empty { + padding: 0; + } + + div.breadcrumbs { + padding: 10px 15px; + } + + /* Dashboard */ + + .colMS, .colSM { + margin: 0; + } + + #content-related, .colSM #content-related { + width: 100%; + margin: 0; + } + + #content-related .module { + margin-bottom: 0; + } + + #content-related .module h2 { + padding: 10px 15px; + font-size: 1rem; + } + + /* Changelist */ + + #changelist { + align-items: stretch; + flex-direction: column; + } + + #toolbar { + padding: 10px; + } + + #changelist-filter { + margin-left: 0; + } + + #changelist .actions label { + flex: 1 1; + } + + #changelist .actions select { + flex: 1 0; + width: 100%; + } + + #changelist .actions span { + flex: 1 0 100%; + } + + #changelist-filter { + position: static; + width: auto; + margin-top: 30px; + } + + .object-tools { + float: none; + margin: 0 0 15px; + padding: 0; + overflow: hidden; + } + + .object-tools li { + height: auto; + margin-left: 0; + } + + .object-tools li + li { + margin-left: 15px; + } + + /* Forms */ + + .form-row { + padding: 15px 0; + } + + .aligned .form-row, + .aligned .form-row > div { + max-width: 100vw; + } + + .aligned .form-row > div { + width: calc(100vw - 30px); + } + + .flex-container { + flex-flow: column; + } + + textarea { + max-width: none; + } + + .vURLField { + width: auto; + } + + fieldset .fieldBox + .fieldBox { + margin-top: 15px; + padding-top: 15px; + } + + fieldset.collapsed .form-row { + display: none; + } + + .aligned label { + width: 100%; + padding: 0 0 10px; + } + + .aligned label:after { + max-height: 0; + } + + .aligned .form-row input, + .aligned .form-row select, + .aligned .form-row textarea { + flex: 1 1 auto; + max-width: 100%; + } + + .aligned .checkbox-row { + align-items: center; + } + + .aligned .checkbox-row input { + flex: 0 1 auto; + margin: 0; + } + + .aligned .vCheckboxLabel { + flex: 1 0; + padding: 1px 0 0 5px; + } + + .aligned label + p, + .aligned label + div.help, + .aligned label + div.readonly { + padding: 0; + margin-left: 0; + } + + .aligned p.file-upload { + font-size: 0.8125rem; + } + + span.clearable-file-input { + margin-left: 15px; + } + + span.clearable-file-input label { + font-size: 0.8125rem; + padding-bottom: 0; + } + + .aligned .timezonewarning { + flex: 1 0 100%; + margin-top: 5px; + } + + form .aligned .form-row div.help { + width: 100%; + margin: 5px 0 0; + padding: 0; + } + + form .aligned ul, + form .aligned ul.errorlist { + margin-left: 0; + padding-left: 0; + } + + form .aligned div.radiolist { + margin-top: 5px; + margin-right: 15px; + margin-bottom: -3px; + } + + form .aligned div.radiolist:not(.inline) div + div { + margin-top: 5px; + } + + /* Related widget */ + + .related-widget-wrapper { + width: 100%; + display: flex; + align-items: flex-start; + } + + .related-widget-wrapper .selector { + order: 1; + } + + .related-widget-wrapper > a { + order: 2; + } + + .related-widget-wrapper .radiolist ~ a { + align-self: flex-end; + } + + .related-widget-wrapper > select ~ a { + align-self: center; + } + + select + .related-widget-wrapper-link, + .related-widget-wrapper-link + .related-widget-wrapper-link { + margin-left: 15px; + } + + /* Selector */ + + .selector { + flex-direction: column; + } + + .selector > * { + float: none; + } + + .selector-available, .selector-chosen { + margin-bottom: 0; + flex: 1 1 auto; + } + + .selector select { + max-height: 96px; + } + + .selector ul.selector-chooser { + display: block; + float: none; + width: 52px; + height: 26px; + padding: 0 2px; + margin: 15px auto 20px; + transform: none; + } + + .selector ul.selector-chooser li { + float: left; + } + + .selector-remove { + background-position: 0 0; + } + + .active.selector-remove:focus, .active.selector-remove:hover { + background-position: 0 -20px; + } + + .selector-add { + background-position: 0 -40px; + } + + .active.selector-add:focus, .active.selector-add:hover { + background-position: 0 -60px; + } + + /* Inlines */ + + .inline-group[data-inline-type="stacked"] .inline-related { + border: 1px solid var(--hairline-color); + border-radius: 4px; + margin-top: 15px; + overflow: auto; + } + + .inline-group[data-inline-type="stacked"] .inline-related > * { + box-sizing: border-box; + } + + .inline-group[data-inline-type="stacked"] .inline-related .module { + padding: 0 10px; + } + + .inline-group[data-inline-type="stacked"] .inline-related .module .form-row { + border-top: 1px solid var(--hairline-color); + border-bottom: none; + } + + .inline-group[data-inline-type="stacked"] .inline-related .module .form-row:first-child { + border-top: none; + } + + .inline-group[data-inline-type="stacked"] .inline-related h3 { + padding: 10px; + border-top-width: 0; + border-bottom-width: 2px; + display: flex; + flex-wrap: wrap; + align-items: center; + } + + .inline-group[data-inline-type="stacked"] .inline-related h3 .inline_label { + margin-right: auto; + } + + .inline-group[data-inline-type="stacked"] .inline-related h3 span.delete { + float: none; + flex: 1 1 100%; + margin-top: 5px; + } + + .inline-group[data-inline-type="stacked"] .aligned .form-row > div:not([class]) { + width: 100%; + } + + .inline-group[data-inline-type="stacked"] .aligned label { + width: 100%; + } + + .inline-group[data-inline-type="stacked"] div.add-row { + margin-top: 15px; + border: 1px solid var(--hairline-color); + border-radius: 4px; + } + + .inline-group div.add-row, + .inline-group .tabular tr.add-row td { + padding: 0; + } + + .inline-group div.add-row a, + .inline-group .tabular tr.add-row td a { + display: block; + padding: 8px 10px 8px 26px; + background-position: 8px 9px; + } + + /* Submit row */ + + .submit-row { + padding: 10px; + margin: 0 0 15px; + flex-direction: column; + gap: 8px; + } + + .submit-row input, .submit-row input.default, .submit-row a { + text-align: center; + } + + .submit-row a.closelink { + padding: 10px 0; + text-align: center; + } + + .submit-row a.deletelink { + margin: 0; + } + + /* Messages */ + + ul.messagelist li { + padding-left: 40px; + background-position: 15px 12px; + } + + ul.messagelist li.error { + background-position: 15px 12px; + } + + ul.messagelist li.warning { + background-position: 15px 14px; + } + + /* Paginator */ + + .paginator .this-page, .paginator a:link, .paginator a:visited { + padding: 4px 10px; + } + + /* Login */ + + body.login { + padding: 0 15px; + } + + .login #container { + width: auto; + max-width: 480px; + margin: 50px auto; + } + + .login #header, + .login #content { + padding: 15px; + } + + .login #content-main { + float: none; + } + + .login .form-row { + padding: 0; + } + + .login .form-row + .form-row { + margin-top: 15px; + } + + .login .form-row label { + margin: 0 0 5px; + line-height: 1.2; + } + + .login .submit-row { + padding: 15px 0 0; + } + + .login br { + display: none; + } + + .login .submit-row input { + margin: 0; + text-transform: uppercase; + } + + .errornote { + margin: 0 0 20px; + padding: 8px 12px; + font-size: 0.8125rem; + } + + /* Calendar and clock */ + + .calendarbox, .clockbox { + position: fixed !important; + top: 50% !important; + left: 50% !important; + transform: translate(-50%, -50%); + margin: 0; + border: none; + overflow: visible; + } + + .calendarbox:before, .clockbox:before { + content: ''; + position: fixed; + top: 50%; + left: 50%; + width: 100vw; + height: 100vh; + background: rgba(0, 0, 0, 0.75); + transform: translate(-50%, -50%); + } + + .calendarbox > *, .clockbox > * { + position: relative; + z-index: 1; + } + + .calendarbox > div:first-child { + z-index: 2; + } + + .calendarbox .calendar, .clockbox h2 { + border-radius: 4px 4px 0 0; + overflow: hidden; + } + + .calendarbox .calendar-cancel, .clockbox .calendar-cancel { + border-radius: 0 0 4px 4px; + overflow: hidden; + } + + .calendar-shortcuts { + padding: 10px 0; + font-size: 0.75rem; + line-height: 0.75rem; + } + + .calendar-shortcuts a { + margin: 0 4px; + } + + .timelist a { + background: var(--body-bg); + padding: 4px; + } + + .calendar-cancel { + padding: 8px 10px; + } + + .clockbox h2 { + padding: 8px 15px; + } + + .calendar caption { + padding: 10px; + } + + .calendarbox .calendarnav-previous, .calendarbox .calendarnav-next { + z-index: 1; + top: 10px; + } + + /* History */ + + table#change-history tbody th, table#change-history tbody td { + font-size: 0.8125rem; + word-break: break-word; + } + + table#change-history tbody th { + width: auto; + } + + /* Docs */ + + table.model tbody th, table.model tbody td { + font-size: 0.8125rem; + word-break: break-word; + } +} diff --git a/static/admin/css/responsive_rtl.css b/static/admin/css/responsive_rtl.css new file mode 100644 index 0000000..639e20b --- /dev/null +++ b/static/admin/css/responsive_rtl.css @@ -0,0 +1,81 @@ +/* TABLETS */ + +@media (max-width: 1024px) { + [dir="rtl"] .colMS { + margin-right: 0; + } + + [dir="rtl"] #user-tools { + text-align: right; + } + + [dir="rtl"] #changelist .actions label { + padding-left: 10px; + padding-right: 0; + } + + [dir="rtl"] #changelist .actions select { + margin-left: 0; + margin-right: 15px; + } + + [dir="rtl"] .change-list .filtered .results, + [dir="rtl"] .change-list .filtered .paginator, + [dir="rtl"] .filtered #toolbar, + [dir="rtl"] .filtered div.xfull, + [dir="rtl"] .filtered .actions, + [dir="rtl"] #changelist-filter { + margin-left: 0; + } + + [dir="rtl"] .inline-group ul.tools a.add, + [dir="rtl"] .inline-group div.add-row a, + [dir="rtl"] .inline-group .tabular tr.add-row td a { + padding: 8px 26px 8px 10px; + background-position: calc(100% - 8px) 9px; + } + + [dir="rtl"] .related-widget-wrapper-link + .selector { + margin-right: 0; + margin-left: 15px; + } + + [dir="rtl"] .selector .selector-filter label { + margin-right: 0; + margin-left: 8px; + } + + [dir="rtl"] .object-tools li { + float: right; + } + + [dir="rtl"] .object-tools li + li { + margin-left: 0; + margin-right: 15px; + } + + [dir="rtl"] .dashboard .module table td a { + padding-left: 0; + padding-right: 16px; + } +} + +/* MOBILE */ + +@media (max-width: 767px) { + [dir="rtl"] .aligned .related-lookup, + [dir="rtl"] .aligned .datetimeshortcuts { + margin-left: 0; + margin-right: 15px; + } + + [dir="rtl"] .aligned ul, + [dir="rtl"] form .aligned ul.errorlist { + margin-right: 0; + } + + [dir="rtl"] #changelist-filter { + margin-left: 0; + margin-right: 0; + } +} diff --git a/static/admin/css/rtl.css b/static/admin/css/rtl.css new file mode 100644 index 0000000..53a6dd6 --- /dev/null +++ b/static/admin/css/rtl.css @@ -0,0 +1,288 @@ +/* GLOBAL */ + +th { + text-align: right; +} + +.module h2, .module caption { + text-align: right; +} + +.module ul, .module ol { + margin-left: 0; + margin-right: 1.5em; +} + +.viewlink, .addlink, .changelink { + padding-left: 0; + padding-right: 16px; + background-position: 100% 1px; +} + +.deletelink { + padding-left: 0; + padding-right: 16px; + background-position: 100% 1px; +} + +.object-tools { + float: left; +} + +thead th:first-child, +tfoot td:first-child { + border-left: none; +} + +/* LAYOUT */ + +#user-tools { + right: auto; + left: 0; + text-align: left; +} + +div.breadcrumbs { + text-align: right; +} + +#content-main { + float: right; +} + +#content-related { + float: left; + margin-left: -300px; + margin-right: auto; +} + +.colMS { + margin-left: 300px; + margin-right: 0; +} + +/* SORTABLE TABLES */ + +table thead th.sorted .sortoptions { + float: left; +} + +thead th.sorted .text { + padding-right: 0; + padding-left: 42px; +} + +/* dashboard styles */ + +.dashboard .module table td a { + padding-left: .6em; + padding-right: 16px; +} + +/* changelists styles */ + +.change-list .filtered table { + border-left: none; + border-right: 0px none; +} + +#changelist-filter { + border-left: none; + border-right: none; + margin-left: 0; + margin-right: 30px; +} + +#changelist-filter li.selected { + border-left: none; + padding-left: 10px; + margin-left: 0; + border-right: 5px solid var(--hairline-color); + padding-right: 10px; + margin-right: -15px; +} + +#changelist table tbody td:first-child, #changelist table tbody th:first-child { + border-right: none; + border-left: none; +} + +/* FORMS */ + +.aligned label { + padding: 0 0 3px 1em; +} + +.submit-row a.deletelink { + margin-left: 0; + margin-right: auto; +} + +.vDateField, .vTimeField { + margin-left: 2px; +} + +.aligned .form-row input { + margin-left: 5px; +} + +form .aligned ul { + margin-right: 163px; + padding-right: 10px; + margin-left: 0; + padding-left: 0; +} + +form ul.inline li { + float: right; + padding-right: 0; + padding-left: 7px; +} + +form .aligned p.help, +form .aligned div.help { + margin-right: 160px; + padding-right: 10px; +} + +form div.help ul, +form .aligned .checkbox-row + .help, +form .aligned p.date div.help.timezonewarning, +form .aligned p.datetime div.help.timezonewarning, +form .aligned p.time div.help.timezonewarning { + margin-right: 0; + padding-right: 0; +} + +form .wide p.help, form .wide div.help { + padding-left: 0; + padding-right: 50px; +} + +form .wide p, +form .wide ul.errorlist, +form .wide input + p.help, +form .wide input + div.help { + margin-right: 200px; + margin-left: 0px; +} + +.submit-row { + text-align: right; +} + +fieldset .fieldBox { + margin-left: 20px; + margin-right: 0; +} + +.errorlist li { + background-position: 100% 12px; + padding: 0; +} + +.errornote { + background-position: 100% 12px; + padding: 10px 12px; +} + +/* WIDGETS */ + +.calendarnav-previous { + top: 0; + left: auto; + right: 10px; + background: url(../img/calendar-icons.svg) 0 -30px no-repeat; +} + +.calendarbox .calendarnav-previous:focus, +.calendarbox .calendarnav-previous:hover { + background-position: 0 -45px; +} + +.calendarnav-next { + top: 0; + right: auto; + left: 10px; + background: url(../img/calendar-icons.svg) 0 0 no-repeat; +} + +.calendarbox .calendarnav-next:focus, +.calendarbox .calendarnav-next:hover { + background-position: 0 -15px; +} + +.calendar caption, .calendarbox h2 { + text-align: center; +} + +.selector { + float: right; +} + +.selector .selector-filter { + text-align: right; +} + +.selector-add { + background: url(../img/selector-icons.svg) 0 -64px no-repeat; +} + +.active.selector-add:focus, .active.selector-add:hover { + background-position: 0 -80px; +} + +.selector-remove { + background: url(../img/selector-icons.svg) 0 -96px no-repeat; +} + +.active.selector-remove:focus, .active.selector-remove:hover { + background-position: 0 -112px; +} + +a.selector-chooseall { + background: url(../img/selector-icons.svg) right -128px no-repeat; +} + +a.active.selector-chooseall:focus, a.active.selector-chooseall:hover { + background-position: 100% -144px; +} + +a.selector-clearall { + background: url(../img/selector-icons.svg) 0 -160px no-repeat; +} + +a.active.selector-clearall:focus, a.active.selector-clearall:hover { + background-position: 0 -176px; +} + +.inline-deletelink { + float: left; +} + +form .form-row p.datetime { + overflow: hidden; +} + +.related-widget-wrapper { + float: right; +} + +/* MISC */ + +.inline-related h2, .inline-group h2 { + text-align: right +} + +.inline-related h3 span.delete { + padding-right: 20px; + padding-left: inherit; + left: 10px; + right: inherit; + float:left; +} + +.inline-related h3 span.delete label { + margin-left: inherit; + margin-right: 2px; +} diff --git a/static/admin/css/vendor/select2/LICENSE-SELECT2.md b/static/admin/css/vendor/select2/LICENSE-SELECT2.md new file mode 100644 index 0000000..8cb8a2b --- /dev/null +++ b/static/admin/css/vendor/select2/LICENSE-SELECT2.md @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2012-2017 Kevin Brown, Igor Vaynberg, and Select2 contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/static/admin/css/vendor/select2/select2.css b/static/admin/css/vendor/select2/select2.css new file mode 100644 index 0000000..750b320 --- /dev/null +++ b/static/admin/css/vendor/select2/select2.css @@ -0,0 +1,481 @@ +.select2-container { + box-sizing: border-box; + display: inline-block; + margin: 0; + position: relative; + vertical-align: middle; } + .select2-container .select2-selection--single { + box-sizing: border-box; + cursor: pointer; + display: block; + height: 28px; + user-select: none; + -webkit-user-select: none; } + .select2-container .select2-selection--single .select2-selection__rendered { + display: block; + padding-left: 8px; + padding-right: 20px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } + .select2-container .select2-selection--single .select2-selection__clear { + position: relative; } + .select2-container[dir="rtl"] .select2-selection--single .select2-selection__rendered { + padding-right: 8px; + padding-left: 20px; } + .select2-container .select2-selection--multiple { + box-sizing: border-box; + cursor: pointer; + display: block; + min-height: 32px; + user-select: none; + -webkit-user-select: none; } + .select2-container .select2-selection--multiple .select2-selection__rendered { + display: inline-block; + overflow: hidden; + padding-left: 8px; + text-overflow: ellipsis; + white-space: nowrap; } + .select2-container .select2-search--inline { + float: left; } + .select2-container .select2-search--inline .select2-search__field { + box-sizing: border-box; + border: none; + font-size: 100%; + margin-top: 5px; + padding: 0; } + .select2-container .select2-search--inline .select2-search__field::-webkit-search-cancel-button { + -webkit-appearance: none; } + +.select2-dropdown { + background-color: white; + border: 1px solid #aaa; + border-radius: 4px; + box-sizing: border-box; + display: block; + position: absolute; + left: -100000px; + width: 100%; + z-index: 1051; } + +.select2-results { + display: block; } + +.select2-results__options { + list-style: none; + margin: 0; + padding: 0; } + +.select2-results__option { + padding: 6px; + user-select: none; + -webkit-user-select: none; } + .select2-results__option[aria-selected] { + cursor: pointer; } + +.select2-container--open .select2-dropdown { + left: 0; } + +.select2-container--open .select2-dropdown--above { + border-bottom: none; + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; } + +.select2-container--open .select2-dropdown--below { + border-top: none; + border-top-left-radius: 0; + border-top-right-radius: 0; } + +.select2-search--dropdown { + display: block; + padding: 4px; } + .select2-search--dropdown .select2-search__field { + padding: 4px; + width: 100%; + box-sizing: border-box; } + .select2-search--dropdown .select2-search__field::-webkit-search-cancel-button { + -webkit-appearance: none; } + .select2-search--dropdown.select2-search--hide { + display: none; } + +.select2-close-mask { + border: 0; + margin: 0; + padding: 0; + display: block; + position: fixed; + left: 0; + top: 0; + min-height: 100%; + min-width: 100%; + height: auto; + width: auto; + opacity: 0; + z-index: 99; + background-color: #fff; + filter: alpha(opacity=0); } + +.select2-hidden-accessible { + border: 0 !important; + clip: rect(0 0 0 0) !important; + -webkit-clip-path: inset(50%) !important; + clip-path: inset(50%) !important; + height: 1px !important; + overflow: hidden !important; + padding: 0 !important; + position: absolute !important; + width: 1px !important; + white-space: nowrap !important; } + +.select2-container--default .select2-selection--single { + background-color: #fff; + border: 1px solid #aaa; + border-radius: 4px; } + .select2-container--default .select2-selection--single .select2-selection__rendered { + color: #444; + line-height: 28px; } + .select2-container--default .select2-selection--single .select2-selection__clear { + cursor: pointer; + float: right; + font-weight: bold; } + .select2-container--default .select2-selection--single .select2-selection__placeholder { + color: #999; } + .select2-container--default .select2-selection--single .select2-selection__arrow { + height: 26px; + position: absolute; + top: 1px; + right: 1px; + width: 20px; } + .select2-container--default .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--default[dir="rtl"] .select2-selection--single .select2-selection__clear { + float: left; } + +.select2-container--default[dir="rtl"] .select2-selection--single .select2-selection__arrow { + left: 1px; + right: auto; } + +.select2-container--default.select2-container--disabled .select2-selection--single { + background-color: #eee; + cursor: default; } + .select2-container--default.select2-container--disabled .select2-selection--single .select2-selection__clear { + display: none; } + +.select2-container--default.select2-container--open .select2-selection--single .select2-selection__arrow b { + border-color: transparent transparent #888 transparent; + border-width: 0 4px 5px 4px; } + +.select2-container--default .select2-selection--multiple { + background-color: white; + border: 1px solid #aaa; + border-radius: 4px; + cursor: text; } + .select2-container--default .select2-selection--multiple .select2-selection__rendered { + box-sizing: border-box; + list-style: none; + margin: 0; + padding: 0 5px; + width: 100%; } + .select2-container--default .select2-selection--multiple .select2-selection__rendered li { + list-style: none; } + .select2-container--default .select2-selection--multiple .select2-selection__clear { + cursor: pointer; + float: right; + font-weight: bold; + margin-top: 5px; + margin-right: 10px; + padding: 1px; } + .select2-container--default .select2-selection--multiple .select2-selection__choice { + background-color: #e4e4e4; + border: 1px solid #aaa; + border-radius: 4px; + cursor: default; + float: left; + margin-right: 5px; + margin-top: 5px; + padding: 0 5px; } + .select2-container--default .select2-selection--multiple .select2-selection__choice__remove { + color: #999; + cursor: pointer; + display: inline-block; + font-weight: bold; + margin-right: 2px; } + .select2-container--default .select2-selection--multiple .select2-selection__choice__remove:hover { + color: #333; } + +.select2-container--default[dir="rtl"] .select2-selection--multiple .select2-selection__choice, .select2-container--default[dir="rtl"] .select2-selection--multiple .select2-search--inline { + float: right; } + +.select2-container--default[dir="rtl"] .select2-selection--multiple .select2-selection__choice { + margin-left: 5px; + margin-right: auto; } + +.select2-container--default[dir="rtl"] .select2-selection--multiple .select2-selection__choice__remove { + margin-left: 2px; + margin-right: auto; } + +.select2-container--default.select2-container--focus .select2-selection--multiple { + border: solid black 1px; + outline: 0; } + +.select2-container--default.select2-container--disabled .select2-selection--multiple { + background-color: #eee; + cursor: default; } + +.select2-container--default.select2-container--disabled .select2-selection__choice__remove { + display: none; } + +.select2-container--default.select2-container--open.select2-container--above .select2-selection--single, .select2-container--default.select2-container--open.select2-container--above .select2-selection--multiple { + border-top-left-radius: 0; + border-top-right-radius: 0; } + +.select2-container--default.select2-container--open.select2-container--below .select2-selection--single, .select2-container--default.select2-container--open.select2-container--below .select2-selection--multiple { + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; } + +.select2-container--default .select2-search--dropdown .select2-search__field { + border: 1px solid #aaa; } + +.select2-container--default .select2-search--inline .select2-search__field { + background: transparent; + border: none; + outline: 0; + box-shadow: none; + -webkit-appearance: textfield; } + +.select2-container--default .select2-results > .select2-results__options { + max-height: 200px; + overflow-y: auto; } + +.select2-container--default .select2-results__option[role=group] { + padding: 0; } + +.select2-container--default .select2-results__option[aria-disabled=true] { + color: #999; } + +.select2-container--default .select2-results__option[aria-selected=true] { + background-color: #ddd; } + +.select2-container--default .select2-results__option .select2-results__option { + padding-left: 1em; } + .select2-container--default .select2-results__option .select2-results__option .select2-results__group { + padding-left: 0; } + .select2-container--default .select2-results__option .select2-results__option .select2-results__option { + margin-left: -1em; + padding-left: 2em; } + .select2-container--default .select2-results__option .select2-results__option .select2-results__option .select2-results__option { + margin-left: -2em; + padding-left: 3em; } + .select2-container--default .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option { + margin-left: -3em; + padding-left: 4em; } + .select2-container--default .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--default .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--default .select2-results__option--highlighted[aria-selected] { + background-color: #5897fb; + color: white; } + +.select2-container--default .select2-results__group { + cursor: default; + display: block; + padding: 6px; } + +.select2-container--classic .select2-selection--single { + background-color: #f7f7f7; + border: 1px solid #aaa; + border-radius: 4px; + outline: 0; + background-image: -webkit-linear-gradient(top, white 50%, #eeeeee 100%); + background-image: -o-linear-gradient(top, white 50%, #eeeeee 100%); + background-image: linear-gradient(to bottom, white 50%, #eeeeee 100%); + background-repeat: repeat-x; + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#FFFFFFFF', endColorstr='#FFEEEEEE', GradientType=0); } + .select2-container--classic .select2-selection--single:focus { + border: 1px solid #5897fb; } + .select2-container--classic .select2-selection--single .select2-selection__rendered { + color: #444; + line-height: 28px; } + .select2-container--classic .select2-selection--single .select2-selection__clear { + cursor: pointer; + float: right; + font-weight: bold; + margin-right: 10px; } + .select2-container--classic .select2-selection--single .select2-selection__placeholder { + color: #999; } + .select2-container--classic .select2-selection--single .select2-selection__arrow { + background-color: #ddd; + border: none; + border-left: 1px solid #aaa; + border-top-right-radius: 4px; + border-bottom-right-radius: 4px; + height: 26px; + position: absolute; + top: 1px; + right: 1px; + width: 20px; + background-image: -webkit-linear-gradient(top, #eeeeee 50%, #cccccc 100%); + background-image: -o-linear-gradient(top, #eeeeee 50%, #cccccc 100%); + background-image: linear-gradient(to bottom, #eeeeee 50%, #cccccc 100%); + background-repeat: repeat-x; + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#FFEEEEEE', endColorstr='#FFCCCCCC', GradientType=0); } + .select2-container--classic .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--classic[dir="rtl"] .select2-selection--single .select2-selection__clear { + float: left; } + +.select2-container--classic[dir="rtl"] .select2-selection--single .select2-selection__arrow { + border: none; + border-right: 1px solid #aaa; + border-radius: 0; + border-top-left-radius: 4px; + border-bottom-left-radius: 4px; + left: 1px; + right: auto; } + +.select2-container--classic.select2-container--open .select2-selection--single { + border: 1px solid #5897fb; } + .select2-container--classic.select2-container--open .select2-selection--single .select2-selection__arrow { + background: transparent; + border: none; } + .select2-container--classic.select2-container--open .select2-selection--single .select2-selection__arrow b { + border-color: transparent transparent #888 transparent; + border-width: 0 4px 5px 4px; } + +.select2-container--classic.select2-container--open.select2-container--above .select2-selection--single { + border-top: none; + border-top-left-radius: 0; + border-top-right-radius: 0; + background-image: -webkit-linear-gradient(top, white 0%, #eeeeee 50%); + background-image: -o-linear-gradient(top, white 0%, #eeeeee 50%); + background-image: linear-gradient(to bottom, white 0%, #eeeeee 50%); + background-repeat: repeat-x; + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#FFFFFFFF', endColorstr='#FFEEEEEE', GradientType=0); } + +.select2-container--classic.select2-container--open.select2-container--below .select2-selection--single { + border-bottom: none; + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; + background-image: -webkit-linear-gradient(top, #eeeeee 50%, white 100%); + background-image: -o-linear-gradient(top, #eeeeee 50%, white 100%); + background-image: linear-gradient(to bottom, #eeeeee 50%, white 100%); + background-repeat: repeat-x; + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#FFEEEEEE', endColorstr='#FFFFFFFF', GradientType=0); } + +.select2-container--classic .select2-selection--multiple { + background-color: white; + border: 1px solid #aaa; + border-radius: 4px; + cursor: text; + outline: 0; } + .select2-container--classic .select2-selection--multiple:focus { + border: 1px solid #5897fb; } + .select2-container--classic .select2-selection--multiple .select2-selection__rendered { + list-style: none; + margin: 0; + padding: 0 5px; } + .select2-container--classic .select2-selection--multiple .select2-selection__clear { + display: none; } + .select2-container--classic .select2-selection--multiple .select2-selection__choice { + background-color: #e4e4e4; + border: 1px solid #aaa; + border-radius: 4px; + cursor: default; + float: left; + margin-right: 5px; + margin-top: 5px; + padding: 0 5px; } + .select2-container--classic .select2-selection--multiple .select2-selection__choice__remove { + color: #888; + cursor: pointer; + display: inline-block; + font-weight: bold; + margin-right: 2px; } + .select2-container--classic .select2-selection--multiple .select2-selection__choice__remove:hover { + color: #555; } + +.select2-container--classic[dir="rtl"] .select2-selection--multiple .select2-selection__choice { + float: right; + margin-left: 5px; + margin-right: auto; } + +.select2-container--classic[dir="rtl"] .select2-selection--multiple .select2-selection__choice__remove { + margin-left: 2px; + margin-right: auto; } + +.select2-container--classic.select2-container--open .select2-selection--multiple { + border: 1px solid #5897fb; } + +.select2-container--classic.select2-container--open.select2-container--above .select2-selection--multiple { + border-top: none; + border-top-left-radius: 0; + border-top-right-radius: 0; } + +.select2-container--classic.select2-container--open.select2-container--below .select2-selection--multiple { + border-bottom: none; + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; } + +.select2-container--classic .select2-search--dropdown .select2-search__field { + border: 1px solid #aaa; + outline: 0; } + +.select2-container--classic .select2-search--inline .select2-search__field { + outline: 0; + box-shadow: none; } + +.select2-container--classic .select2-dropdown { + background-color: white; + border: 1px solid transparent; } + +.select2-container--classic .select2-dropdown--above { + border-bottom: none; } + +.select2-container--classic .select2-dropdown--below { + border-top: none; } + +.select2-container--classic .select2-results > .select2-results__options { + max-height: 200px; + overflow-y: auto; } + +.select2-container--classic .select2-results__option[role=group] { + padding: 0; } + +.select2-container--classic .select2-results__option[aria-disabled=true] { + color: grey; } + +.select2-container--classic .select2-results__option--highlighted[aria-selected] { + background-color: #3875d7; + color: white; } + +.select2-container--classic .select2-results__group { + cursor: default; + display: block; + padding: 6px; } + +.select2-container--classic.select2-container--open .select2-dropdown { + border-color: #5897fb; } diff --git a/static/admin/css/vendor/select2/select2.min.css b/static/admin/css/vendor/select2/select2.min.css new file mode 100644 index 0000000..7c18ad5 --- /dev/null +++ b/static/admin/css/vendor/select2/select2.min.css @@ -0,0 +1 @@ +.select2-container{box-sizing:border-box;display:inline-block;margin:0;position:relative;vertical-align:middle}.select2-container .select2-selection--single{box-sizing:border-box;cursor:pointer;display:block;height:28px;user-select:none;-webkit-user-select:none}.select2-container .select2-selection--single .select2-selection__rendered{display:block;padding-left:8px;padding-right:20px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.select2-container .select2-selection--single .select2-selection__clear{position:relative}.select2-container[dir="rtl"] .select2-selection--single .select2-selection__rendered{padding-right:8px;padding-left:20px}.select2-container .select2-selection--multiple{box-sizing:border-box;cursor:pointer;display:block;min-height:32px;user-select:none;-webkit-user-select:none}.select2-container .select2-selection--multiple .select2-selection__rendered{display:inline-block;overflow:hidden;padding-left:8px;text-overflow:ellipsis;white-space:nowrap}.select2-container .select2-search--inline{float:left}.select2-container .select2-search--inline .select2-search__field{box-sizing:border-box;border:none;font-size:100%;margin-top:5px;padding:0}.select2-container .select2-search--inline .select2-search__field::-webkit-search-cancel-button{-webkit-appearance:none}.select2-dropdown{background-color:white;border:1px solid #aaa;border-radius:4px;box-sizing:border-box;display:block;position:absolute;left:-100000px;width:100%;z-index:1051}.select2-results{display:block}.select2-results__options{list-style:none;margin:0;padding:0}.select2-results__option{padding:6px;user-select:none;-webkit-user-select:none}.select2-results__option[aria-selected]{cursor:pointer}.select2-container--open .select2-dropdown{left:0}.select2-container--open .select2-dropdown--above{border-bottom:none;border-bottom-left-radius:0;border-bottom-right-radius:0}.select2-container--open .select2-dropdown--below{border-top:none;border-top-left-radius:0;border-top-right-radius:0}.select2-search--dropdown{display:block;padding:4px}.select2-search--dropdown .select2-search__field{padding:4px;width:100%;box-sizing:border-box}.select2-search--dropdown .select2-search__field::-webkit-search-cancel-button{-webkit-appearance:none}.select2-search--dropdown.select2-search--hide{display:none}.select2-close-mask{border:0;margin:0;padding:0;display:block;position:fixed;left:0;top:0;min-height:100%;min-width:100%;height:auto;width:auto;opacity:0;z-index:99;background-color:#fff;filter:alpha(opacity=0)}.select2-hidden-accessible{border:0 !important;clip:rect(0 0 0 0) !important;-webkit-clip-path:inset(50%) !important;clip-path:inset(50%) !important;height:1px !important;overflow:hidden !important;padding:0 !important;position:absolute !important;width:1px !important;white-space:nowrap !important}.select2-container--default .select2-selection--single{background-color:#fff;border:1px solid #aaa;border-radius:4px}.select2-container--default .select2-selection--single .select2-selection__rendered{color:#444;line-height:28px}.select2-container--default .select2-selection--single .select2-selection__clear{cursor:pointer;float:right;font-weight:bold}.select2-container--default .select2-selection--single .select2-selection__placeholder{color:#999}.select2-container--default .select2-selection--single .select2-selection__arrow{height:26px;position:absolute;top:1px;right:1px;width:20px}.select2-container--default .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--default[dir="rtl"] .select2-selection--single .select2-selection__clear{float:left}.select2-container--default[dir="rtl"] .select2-selection--single .select2-selection__arrow{left:1px;right:auto}.select2-container--default.select2-container--disabled .select2-selection--single{background-color:#eee;cursor:default}.select2-container--default.select2-container--disabled .select2-selection--single .select2-selection__clear{display:none}.select2-container--default.select2-container--open .select2-selection--single .select2-selection__arrow b{border-color:transparent transparent #888 transparent;border-width:0 4px 5px 4px}.select2-container--default .select2-selection--multiple{background-color:white;border:1px solid #aaa;border-radius:4px;cursor:text}.select2-container--default .select2-selection--multiple .select2-selection__rendered{box-sizing:border-box;list-style:none;margin:0;padding:0 5px;width:100%}.select2-container--default .select2-selection--multiple .select2-selection__rendered li{list-style:none}.select2-container--default .select2-selection--multiple .select2-selection__clear{cursor:pointer;float:right;font-weight:bold;margin-top:5px;margin-right:10px;padding:1px}.select2-container--default .select2-selection--multiple .select2-selection__choice{background-color:#e4e4e4;border:1px solid #aaa;border-radius:4px;cursor:default;float:left;margin-right:5px;margin-top:5px;padding:0 5px}.select2-container--default .select2-selection--multiple .select2-selection__choice__remove{color:#999;cursor:pointer;display:inline-block;font-weight:bold;margin-right:2px}.select2-container--default .select2-selection--multiple .select2-selection__choice__remove:hover{color:#333}.select2-container--default[dir="rtl"] .select2-selection--multiple .select2-selection__choice,.select2-container--default[dir="rtl"] .select2-selection--multiple .select2-search--inline{float:right}.select2-container--default[dir="rtl"] .select2-selection--multiple .select2-selection__choice{margin-left:5px;margin-right:auto}.select2-container--default[dir="rtl"] .select2-selection--multiple .select2-selection__choice__remove{margin-left:2px;margin-right:auto}.select2-container--default.select2-container--focus .select2-selection--multiple{border:solid black 1px;outline:0}.select2-container--default.select2-container--disabled .select2-selection--multiple{background-color:#eee;cursor:default}.select2-container--default.select2-container--disabled .select2-selection__choice__remove{display:none}.select2-container--default.select2-container--open.select2-container--above .select2-selection--single,.select2-container--default.select2-container--open.select2-container--above .select2-selection--multiple{border-top-left-radius:0;border-top-right-radius:0}.select2-container--default.select2-container--open.select2-container--below .select2-selection--single,.select2-container--default.select2-container--open.select2-container--below .select2-selection--multiple{border-bottom-left-radius:0;border-bottom-right-radius:0}.select2-container--default .select2-search--dropdown .select2-search__field{border:1px solid #aaa}.select2-container--default .select2-search--inline .select2-search__field{background:transparent;border:none;outline:0;box-shadow:none;-webkit-appearance:textfield}.select2-container--default .select2-results>.select2-results__options{max-height:200px;overflow-y:auto}.select2-container--default .select2-results__option[role=group]{padding:0}.select2-container--default .select2-results__option[aria-disabled=true]{color:#999}.select2-container--default .select2-results__option[aria-selected=true]{background-color:#ddd}.select2-container--default .select2-results__option .select2-results__option{padding-left:1em}.select2-container--default .select2-results__option .select2-results__option .select2-results__group{padding-left:0}.select2-container--default .select2-results__option .select2-results__option .select2-results__option{margin-left:-1em;padding-left:2em}.select2-container--default .select2-results__option .select2-results__option .select2-results__option .select2-results__option{margin-left:-2em;padding-left:3em}.select2-container--default .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option{margin-left:-3em;padding-left:4em}.select2-container--default .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--default .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--default .select2-results__option--highlighted[aria-selected]{background-color:#5897fb;color:white}.select2-container--default .select2-results__group{cursor:default;display:block;padding:6px}.select2-container--classic .select2-selection--single{background-color:#f7f7f7;border:1px solid #aaa;border-radius:4px;outline:0;background-image:-webkit-linear-gradient(top, #fff 50%, #eee 100%);background-image:-o-linear-gradient(top, #fff 50%, #eee 100%);background-image:linear-gradient(to bottom, #fff 50%, #eee 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#FFFFFFFF', endColorstr='#FFEEEEEE', GradientType=0)}.select2-container--classic .select2-selection--single:focus{border:1px solid #5897fb}.select2-container--classic .select2-selection--single .select2-selection__rendered{color:#444;line-height:28px}.select2-container--classic .select2-selection--single .select2-selection__clear{cursor:pointer;float:right;font-weight:bold;margin-right:10px}.select2-container--classic .select2-selection--single .select2-selection__placeholder{color:#999}.select2-container--classic .select2-selection--single .select2-selection__arrow{background-color:#ddd;border:none;border-left:1px solid #aaa;border-top-right-radius:4px;border-bottom-right-radius:4px;height:26px;position:absolute;top:1px;right:1px;width:20px;background-image:-webkit-linear-gradient(top, #eee 50%, #ccc 100%);background-image:-o-linear-gradient(top, #eee 50%, #ccc 100%);background-image:linear-gradient(to bottom, #eee 50%, #ccc 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#FFEEEEEE', endColorstr='#FFCCCCCC', GradientType=0)}.select2-container--classic .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--classic[dir="rtl"] .select2-selection--single .select2-selection__clear{float:left}.select2-container--classic[dir="rtl"] .select2-selection--single .select2-selection__arrow{border:none;border-right:1px solid #aaa;border-radius:0;border-top-left-radius:4px;border-bottom-left-radius:4px;left:1px;right:auto}.select2-container--classic.select2-container--open .select2-selection--single{border:1px solid #5897fb}.select2-container--classic.select2-container--open .select2-selection--single .select2-selection__arrow{background:transparent;border:none}.select2-container--classic.select2-container--open .select2-selection--single .select2-selection__arrow b{border-color:transparent transparent #888 transparent;border-width:0 4px 5px 4px}.select2-container--classic.select2-container--open.select2-container--above .select2-selection--single{border-top:none;border-top-left-radius:0;border-top-right-radius:0;background-image:-webkit-linear-gradient(top, #fff 0%, #eee 50%);background-image:-o-linear-gradient(top, #fff 0%, #eee 50%);background-image:linear-gradient(to bottom, #fff 0%, #eee 50%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#FFFFFFFF', endColorstr='#FFEEEEEE', GradientType=0)}.select2-container--classic.select2-container--open.select2-container--below .select2-selection--single{border-bottom:none;border-bottom-left-radius:0;border-bottom-right-radius:0;background-image:-webkit-linear-gradient(top, #eee 50%, #fff 100%);background-image:-o-linear-gradient(top, #eee 50%, #fff 100%);background-image:linear-gradient(to bottom, #eee 50%, #fff 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#FFEEEEEE', endColorstr='#FFFFFFFF', GradientType=0)}.select2-container--classic .select2-selection--multiple{background-color:white;border:1px solid #aaa;border-radius:4px;cursor:text;outline:0}.select2-container--classic .select2-selection--multiple:focus{border:1px solid #5897fb}.select2-container--classic .select2-selection--multiple .select2-selection__rendered{list-style:none;margin:0;padding:0 5px}.select2-container--classic .select2-selection--multiple .select2-selection__clear{display:none}.select2-container--classic .select2-selection--multiple .select2-selection__choice{background-color:#e4e4e4;border:1px solid #aaa;border-radius:4px;cursor:default;float:left;margin-right:5px;margin-top:5px;padding:0 5px}.select2-container--classic .select2-selection--multiple .select2-selection__choice__remove{color:#888;cursor:pointer;display:inline-block;font-weight:bold;margin-right:2px}.select2-container--classic .select2-selection--multiple .select2-selection__choice__remove:hover{color:#555}.select2-container--classic[dir="rtl"] .select2-selection--multiple .select2-selection__choice{float:right;margin-left:5px;margin-right:auto}.select2-container--classic[dir="rtl"] .select2-selection--multiple .select2-selection__choice__remove{margin-left:2px;margin-right:auto}.select2-container--classic.select2-container--open .select2-selection--multiple{border:1px solid #5897fb}.select2-container--classic.select2-container--open.select2-container--above .select2-selection--multiple{border-top:none;border-top-left-radius:0;border-top-right-radius:0}.select2-container--classic.select2-container--open.select2-container--below .select2-selection--multiple{border-bottom:none;border-bottom-left-radius:0;border-bottom-right-radius:0}.select2-container--classic .select2-search--dropdown .select2-search__field{border:1px solid #aaa;outline:0}.select2-container--classic .select2-search--inline .select2-search__field{outline:0;box-shadow:none}.select2-container--classic .select2-dropdown{background-color:#fff;border:1px solid transparent}.select2-container--classic .select2-dropdown--above{border-bottom:none}.select2-container--classic .select2-dropdown--below{border-top:none}.select2-container--classic .select2-results>.select2-results__options{max-height:200px;overflow-y:auto}.select2-container--classic .select2-results__option[role=group]{padding:0}.select2-container--classic .select2-results__option[aria-disabled=true]{color:grey}.select2-container--classic .select2-results__option--highlighted[aria-selected]{background-color:#3875d7;color:#fff}.select2-container--classic .select2-results__group{cursor:default;display:block;padding:6px}.select2-container--classic.select2-container--open .select2-dropdown{border-color:#5897fb} diff --git a/static/admin/css/widgets.css b/static/admin/css/widgets.css new file mode 100644 index 0000000..5f7adcb --- /dev/null +++ b/static/admin/css/widgets.css @@ -0,0 +1,603 @@ +/* SELECTOR (FILTER INTERFACE) */ + +.selector { + width: 800px; + float: left; + display: flex; +} + +.selector select { + width: 380px; + height: 17.2em; + flex: 1 0 auto; +} + +.selector-available, .selector-chosen { + width: 380px; + text-align: center; + margin-bottom: 5px; + display: flex; + flex-direction: column; +} + +.selector-available h2, .selector-chosen h2 { + border: 1px solid var(--border-color); + border-radius: 4px 4px 0 0; +} + +.selector-chosen .list-footer-display { + border: 1px solid var(--border-color); + border-top: none; + border-radius: 0 0 4px 4px; + margin: 0 0 10px; + padding: 8px; + text-align: center; + background: var(--primary); + color: var(--header-link-color); + cursor: pointer; +} +.selector-chosen .list-footer-display__clear { + color: var(--breadcrumbs-fg); +} + +.selector-chosen h2 { + background: var(--primary); + color: var(--header-link-color); +} + +.selector .selector-available h2 { + background: var(--darkened-bg); + color: var(--body-quiet-color); +} + +.selector .selector-filter { + border: 1px solid var(--border-color); + border-width: 0 1px; + padding: 8px; + color: var(--body-quiet-color); + font-size: 0.625rem; + margin: 0; + text-align: left; +} + +.selector .selector-filter label, +.inline-group .aligned .selector .selector-filter label { + float: left; + margin: 7px 0 0; + width: 18px; + height: 18px; + padding: 0; + overflow: hidden; + line-height: 1; +} + +.selector .selector-available input, +.selector .selector-chosen input { + width: 320px; + margin-left: 8px; +} + +.selector ul.selector-chooser { + align-self: center; + width: 22px; + background-color: var(--selected-bg); + border-radius: 10px; + margin: 0 5px; + padding: 0; + transform: translateY(-17px); +} + +.selector-chooser li { + margin: 0; + padding: 3px; + list-style-type: none; +} + +.selector select { + padding: 0 10px; + margin: 0 0 10px; + border-radius: 0 0 4px 4px; +} +.selector .selector-chosen--with-filtered select { + margin: 0; + border-radius: 0; + height: 14em; +} + +.selector .selector-chosen:not(.selector-chosen--with-filtered) .list-footer-display { + display: none; +} + +.selector-add, .selector-remove { + width: 16px; + height: 16px; + display: block; + text-indent: -3000px; + overflow: hidden; + cursor: default; + opacity: 0.55; +} + +.active.selector-add, .active.selector-remove { + opacity: 1; +} + +.active.selector-add:hover, .active.selector-remove:hover { + cursor: pointer; +} + +.selector-add { + background: url(../img/selector-icons.svg) 0 -96px no-repeat; +} + +.active.selector-add:focus, .active.selector-add:hover { + background-position: 0 -112px; +} + +.selector-remove { + background: url(../img/selector-icons.svg) 0 -64px no-repeat; +} + +.active.selector-remove:focus, .active.selector-remove:hover { + background-position: 0 -80px; +} + +a.selector-chooseall, a.selector-clearall { + display: inline-block; + height: 16px; + text-align: left; + margin: 1px auto 3px; + overflow: hidden; + font-weight: bold; + line-height: 16px; + color: var(--body-quiet-color); + text-decoration: none; + opacity: 0.55; +} + +a.active.selector-chooseall:focus, a.active.selector-clearall:focus, +a.active.selector-chooseall:hover, a.active.selector-clearall:hover { + color: var(--link-fg); +} + +a.active.selector-chooseall, a.active.selector-clearall { + opacity: 1; +} + +a.active.selector-chooseall:hover, a.active.selector-clearall:hover { + cursor: pointer; +} + +a.selector-chooseall { + padding: 0 18px 0 0; + background: url(../img/selector-icons.svg) right -160px no-repeat; + cursor: default; +} + +a.active.selector-chooseall:focus, a.active.selector-chooseall:hover { + background-position: 100% -176px; +} + +a.selector-clearall { + padding: 0 0 0 18px; + background: url(../img/selector-icons.svg) 0 -128px no-repeat; + cursor: default; +} + +a.active.selector-clearall:focus, a.active.selector-clearall:hover { + background-position: 0 -144px; +} + +/* STACKED SELECTORS */ + +.stacked { + float: left; + width: 490px; + display: block; +} + +.stacked select { + width: 480px; + height: 10.1em; +} + +.stacked .selector-available, .stacked .selector-chosen { + width: 480px; +} + +.stacked .selector-available { + margin-bottom: 0; +} + +.stacked .selector-available input { + width: 422px; +} + +.stacked ul.selector-chooser { + height: 22px; + width: 50px; + margin: 0 0 10px 40%; + background-color: #eee; + border-radius: 10px; + transform: none; +} + +.stacked .selector-chooser li { + float: left; + padding: 3px 3px 3px 5px; +} + +.stacked .selector-chooseall, .stacked .selector-clearall { + display: none; +} + +.stacked .selector-add { + background: url(../img/selector-icons.svg) 0 -32px no-repeat; + cursor: default; +} + +.stacked .active.selector-add { + background-position: 0 -32px; + cursor: pointer; +} + +.stacked .active.selector-add:focus, .stacked .active.selector-add:hover { + background-position: 0 -48px; + cursor: pointer; +} + +.stacked .selector-remove { + background: url(../img/selector-icons.svg) 0 0 no-repeat; + cursor: default; +} + +.stacked .active.selector-remove { + background-position: 0 0px; + cursor: pointer; +} + +.stacked .active.selector-remove:focus, .stacked .active.selector-remove:hover { + background-position: 0 -16px; + cursor: pointer; +} + +.selector .help-icon { + background: url(../img/icon-unknown.svg) 0 0 no-repeat; + display: inline-block; + vertical-align: middle; + margin: -2px 0 0 2px; + width: 13px; + height: 13px; +} + +.selector .selector-chosen .help-icon { + background: url(../img/icon-unknown-alt.svg) 0 0 no-repeat; +} + +.selector .search-label-icon { + background: url(../img/search.svg) 0 0 no-repeat; + display: inline-block; + height: 1.125rem; + width: 1.125rem; +} + +/* DATE AND TIME */ + +p.datetime { + line-height: 20px; + margin: 0; + padding: 0; + color: var(--body-quiet-color); + font-weight: bold; +} + +.datetime span { + white-space: nowrap; + font-weight: normal; + font-size: 0.6875rem; + color: var(--body-quiet-color); +} + +.datetime input, .form-row .datetime input.vDateField, .form-row .datetime input.vTimeField { + margin-left: 5px; + margin-bottom: 4px; +} + +table p.datetime { + font-size: 0.6875rem; + margin-left: 0; + padding-left: 0; +} + +.datetimeshortcuts .clock-icon, .datetimeshortcuts .date-icon { + position: relative; + display: inline-block; + vertical-align: middle; + height: 16px; + width: 16px; + overflow: hidden; +} + +.datetimeshortcuts .clock-icon { + background: url(../img/icon-clock.svg) 0 0 no-repeat; +} + +.datetimeshortcuts a:focus .clock-icon, +.datetimeshortcuts a:hover .clock-icon { + background-position: 0 -16px; +} + +.datetimeshortcuts .date-icon { + background: url(../img/icon-calendar.svg) 0 0 no-repeat; + top: -1px; +} + +.datetimeshortcuts a:focus .date-icon, +.datetimeshortcuts a:hover .date-icon { + background-position: 0 -16px; +} + +.timezonewarning { + font-size: 0.6875rem; + color: var(--body-quiet-color); +} + +/* URL */ + +p.url { + line-height: 20px; + margin: 0; + padding: 0; + color: var(--body-quiet-color); + font-size: 0.6875rem; + font-weight: bold; +} + +.url a { + font-weight: normal; +} + +/* FILE UPLOADS */ + +p.file-upload { + line-height: 20px; + margin: 0; + padding: 0; + color: var(--body-quiet-color); + font-size: 0.6875rem; + font-weight: bold; +} + +.file-upload a { + font-weight: normal; +} + +.file-upload .deletelink { + margin-left: 5px; +} + +span.clearable-file-input label { + color: var(--body-fg); + font-size: 0.6875rem; + display: inline; + float: none; +} + +/* CALENDARS & CLOCKS */ + +.calendarbox, .clockbox { + margin: 5px auto; + font-size: 0.75rem; + width: 19em; + text-align: center; + background: var(--body-bg); + color: var(--body-fg); + border: 1px solid var(--hairline-color); + border-radius: 4px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.15); + overflow: hidden; + position: relative; +} + +.clockbox { + width: auto; +} + +.calendar { + margin: 0; + padding: 0; +} + +.calendar table { + margin: 0; + padding: 0; + border-collapse: collapse; + background: white; + width: 100%; +} + +.calendar caption, .calendarbox h2 { + margin: 0; + text-align: center; + border-top: none; + font-weight: 700; + font-size: 0.75rem; + color: #333; + background: var(--accent); +} + +.calendar th { + padding: 8px 5px; + background: var(--darkened-bg); + border-bottom: 1px solid var(--border-color); + font-weight: 400; + font-size: 0.75rem; + text-align: center; + color: var(--body-quiet-color); +} + +.calendar td { + font-weight: 400; + font-size: 0.75rem; + text-align: center; + padding: 0; + border-top: 1px solid var(--hairline-color); + border-bottom: none; +} + +.calendar td.selected a { + background: var(--primary); + color: var(--button-fg); +} + +.calendar td.nonday { + background: var(--darkened-bg); +} + +.calendar td.today a { + font-weight: 700; +} + +.calendar td a, .timelist a { + display: block; + font-weight: 400; + padding: 6px; + text-decoration: none; + color: var(--body-quiet-color); +} + +.calendar td a:focus, .timelist a:focus, +.calendar td a:hover, .timelist a:hover { + background: var(--primary); + color: white; +} + +.calendar td a:active, .timelist a:active { + background: var(--header-bg); + color: white; +} + +.calendarnav { + font-size: 0.625rem; + text-align: center; + color: #ccc; + margin: 0; + padding: 1px 3px; +} + +.calendarnav a:link, #calendarnav a:visited, +#calendarnav a:focus, #calendarnav a:hover { + color: var(--body-quiet-color); +} + +.calendar-shortcuts { + background: var(--body-bg); + color: var(--body-quiet-color); + font-size: 0.6875rem; + line-height: 0.6875rem; + border-top: 1px solid var(--hairline-color); + padding: 8px 0; +} + +.calendarbox .calendarnav-previous, .calendarbox .calendarnav-next { + display: block; + position: absolute; + top: 8px; + width: 15px; + height: 15px; + text-indent: -9999px; + padding: 0; +} + +.calendarnav-previous { + left: 10px; + background: url(../img/calendar-icons.svg) 0 0 no-repeat; +} + +.calendarbox .calendarnav-previous:focus, +.calendarbox .calendarnav-previous:hover { + background-position: 0 -15px; +} + +.calendarnav-next { + right: 10px; + background: url(../img/calendar-icons.svg) 0 -30px no-repeat; +} + +.calendarbox .calendarnav-next:focus, +.calendarbox .calendarnav-next:hover { + background-position: 0 -45px; +} + +.calendar-cancel { + margin: 0; + padding: 4px 0; + font-size: 0.75rem; + background: #eee; + border-top: 1px solid var(--border-color); + color: var(--body-fg); +} + +.calendar-cancel:focus, .calendar-cancel:hover { + background: #ddd; +} + +.calendar-cancel a { + color: black; + display: block; +} + +ul.timelist, .timelist li { + list-style-type: none; + margin: 0; + padding: 0; +} + +.timelist a { + padding: 2px; +} + +/* EDIT INLINE */ + +.inline-deletelink { + float: right; + text-indent: -9999px; + background: url(../img/inline-delete.svg) 0 0 no-repeat; + width: 16px; + height: 16px; + border: 0px none; +} + +.inline-deletelink:focus, .inline-deletelink:hover { + cursor: pointer; +} + +/* RELATED WIDGET WRAPPER */ +.related-widget-wrapper { + float: left; /* display properly in form rows with multiple fields */ + overflow: hidden; /* clear floated contents */ +} + +.related-widget-wrapper-link { + opacity: 0.3; +} + +.related-widget-wrapper-link:link { + opacity: .8; +} + +.related-widget-wrapper-link:link:focus, +.related-widget-wrapper-link:link:hover { + opacity: 1; +} + +select + .related-widget-wrapper-link, +.related-widget-wrapper-link + .related-widget-wrapper-link { + margin-left: 7px; +} + +/* GIS MAPS */ +.dj_map { + width: 600px; + height: 400px; +} diff --git a/static/admin/img/LICENSE b/static/admin/img/LICENSE new file mode 100644 index 0000000..a4faaa1 --- /dev/null +++ b/static/admin/img/LICENSE @@ -0,0 +1,20 @@ +The MIT License (MIT) + +Copyright (c) 2014 Code Charm Ltd + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/static/admin/img/README.txt b/static/admin/img/README.txt new file mode 100644 index 0000000..4eb2e49 --- /dev/null +++ b/static/admin/img/README.txt @@ -0,0 +1,7 @@ +All icons are taken from Font Awesome (http://fontawesome.io/) project. +The Font Awesome font is licensed under the SIL OFL 1.1: +- https://scripts.sil.org/OFL + +SVG icons source: https://github.com/encharm/Font-Awesome-SVG-PNG +Font-Awesome-SVG-PNG is licensed under the MIT license (see file license +in current folder). diff --git a/static/admin/img/calendar-icons.svg b/static/admin/img/calendar-icons.svg new file mode 100644 index 0000000..dbf21c3 --- /dev/null +++ b/static/admin/img/calendar-icons.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/static/admin/img/gis/move_vertex_off.svg b/static/admin/img/gis/move_vertex_off.svg new file mode 100644 index 0000000..228854f --- /dev/null +++ b/static/admin/img/gis/move_vertex_off.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/static/admin/img/gis/move_vertex_on.svg b/static/admin/img/gis/move_vertex_on.svg new file mode 100644 index 0000000..96b87fd --- /dev/null +++ b/static/admin/img/gis/move_vertex_on.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/static/admin/img/icon-addlink.svg b/static/admin/img/icon-addlink.svg new file mode 100644 index 0000000..e004fb1 --- /dev/null +++ b/static/admin/img/icon-addlink.svg @@ -0,0 +1,3 @@ + + + diff --git a/static/admin/img/icon-alert.svg b/static/admin/img/icon-alert.svg new file mode 100644 index 0000000..e51ea83 --- /dev/null +++ b/static/admin/img/icon-alert.svg @@ -0,0 +1,3 @@ + + + diff --git a/static/admin/img/icon-calendar.svg b/static/admin/img/icon-calendar.svg new file mode 100644 index 0000000..97910a9 --- /dev/null +++ b/static/admin/img/icon-calendar.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/static/admin/img/icon-changelink.svg b/static/admin/img/icon-changelink.svg new file mode 100644 index 0000000..bbb137a --- /dev/null +++ b/static/admin/img/icon-changelink.svg @@ -0,0 +1,3 @@ + + + diff --git a/static/admin/img/icon-clock.svg b/static/admin/img/icon-clock.svg new file mode 100644 index 0000000..bf9985d --- /dev/null +++ b/static/admin/img/icon-clock.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/static/admin/img/icon-deletelink.svg b/static/admin/img/icon-deletelink.svg new file mode 100644 index 0000000..4059b15 --- /dev/null +++ b/static/admin/img/icon-deletelink.svg @@ -0,0 +1,3 @@ + + + diff --git a/static/admin/img/icon-no.svg b/static/admin/img/icon-no.svg new file mode 100644 index 0000000..2e0d383 --- /dev/null +++ b/static/admin/img/icon-no.svg @@ -0,0 +1,3 @@ + + + diff --git a/static/admin/img/icon-unknown-alt.svg b/static/admin/img/icon-unknown-alt.svg new file mode 100644 index 0000000..1c6b99f --- /dev/null +++ b/static/admin/img/icon-unknown-alt.svg @@ -0,0 +1,3 @@ + + + diff --git a/static/admin/img/icon-unknown.svg b/static/admin/img/icon-unknown.svg new file mode 100644 index 0000000..50b4f97 --- /dev/null +++ b/static/admin/img/icon-unknown.svg @@ -0,0 +1,3 @@ + + + diff --git a/static/admin/img/icon-viewlink.svg b/static/admin/img/icon-viewlink.svg new file mode 100644 index 0000000..a1ca1d3 --- /dev/null +++ b/static/admin/img/icon-viewlink.svg @@ -0,0 +1,3 @@ + + + diff --git a/static/admin/img/icon-yes.svg b/static/admin/img/icon-yes.svg new file mode 100644 index 0000000..5883d87 --- /dev/null +++ b/static/admin/img/icon-yes.svg @@ -0,0 +1,3 @@ + + + diff --git a/static/admin/img/inline-delete.svg b/static/admin/img/inline-delete.svg new file mode 100644 index 0000000..17d1ad6 --- /dev/null +++ b/static/admin/img/inline-delete.svg @@ -0,0 +1,3 @@ + + + diff --git a/static/admin/img/search.svg b/static/admin/img/search.svg new file mode 100644 index 0000000..c8c69b2 --- /dev/null +++ b/static/admin/img/search.svg @@ -0,0 +1,3 @@ + + + diff --git a/static/admin/img/selector-icons.svg b/static/admin/img/selector-icons.svg new file mode 100644 index 0000000..926b8e2 --- /dev/null +++ b/static/admin/img/selector-icons.svg @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/admin/img/sorting-icons.svg b/static/admin/img/sorting-icons.svg new file mode 100644 index 0000000..7c31ec9 --- /dev/null +++ b/static/admin/img/sorting-icons.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/static/admin/img/tooltag-add.svg b/static/admin/img/tooltag-add.svg new file mode 100644 index 0000000..1ca64ae --- /dev/null +++ b/static/admin/img/tooltag-add.svg @@ -0,0 +1,3 @@ + + + diff --git a/static/admin/img/tooltag-arrowright.svg b/static/admin/img/tooltag-arrowright.svg new file mode 100644 index 0000000..b664d61 --- /dev/null +++ b/static/admin/img/tooltag-arrowright.svg @@ -0,0 +1,3 @@ + + + diff --git a/static/admin/js/SelectBox.js b/static/admin/js/SelectBox.js new file mode 100644 index 0000000..3db4ec7 --- /dev/null +++ b/static/admin/js/SelectBox.js @@ -0,0 +1,116 @@ +'use strict'; +{ + const SelectBox = { + cache: {}, + init: function(id) { + const box = document.getElementById(id); + SelectBox.cache[id] = []; + const cache = SelectBox.cache[id]; + for (const node of box.options) { + cache.push({value: node.value, text: node.text, displayed: 1}); + } + }, + redisplay: function(id) { + // Repopulate HTML select box from cache + const box = document.getElementById(id); + const scroll_value_from_top = box.scrollTop; + box.innerHTML = ''; + for (const node of SelectBox.cache[id]) { + if (node.displayed) { + const new_option = new Option(node.text, node.value, false, false); + // Shows a tooltip when hovering over the option + new_option.title = node.text; + box.appendChild(new_option); + } + } + box.scrollTop = scroll_value_from_top; + }, + filter: function(id, text) { + // Redisplay the HTML select box, displaying only the choices containing ALL + // the words in text. (It's an AND search.) + const tokens = text.toLowerCase().split(/\s+/); + for (const node of SelectBox.cache[id]) { + node.displayed = 1; + const node_text = node.text.toLowerCase(); + for (const token of tokens) { + if (!node_text.includes(token)) { + node.displayed = 0; + break; // Once the first token isn't found we're done + } + } + } + SelectBox.redisplay(id); + }, + get_hidden_node_count(id) { + const cache = SelectBox.cache[id] || []; + return cache.filter(node => node.displayed === 0).length; + }, + delete_from_cache: function(id, value) { + let delete_index = null; + const cache = SelectBox.cache[id]; + for (const [i, node] of cache.entries()) { + if (node.value === value) { + delete_index = i; + break; + } + } + cache.splice(delete_index, 1); + }, + add_to_cache: function(id, option) { + SelectBox.cache[id].push({value: option.value, text: option.text, displayed: 1}); + }, + cache_contains: function(id, value) { + // Check if an item is contained in the cache + for (const node of SelectBox.cache[id]) { + if (node.value === value) { + return true; + } + } + return false; + }, + move: function(from, to) { + const from_box = document.getElementById(from); + for (const option of from_box.options) { + const option_value = option.value; + if (option.selected && SelectBox.cache_contains(from, option_value)) { + SelectBox.add_to_cache(to, {value: option_value, text: option.text, displayed: 1}); + SelectBox.delete_from_cache(from, option_value); + } + } + SelectBox.redisplay(from); + SelectBox.redisplay(to); + }, + move_all: function(from, to) { + const from_box = document.getElementById(from); + for (const option of from_box.options) { + const option_value = option.value; + if (SelectBox.cache_contains(from, option_value)) { + SelectBox.add_to_cache(to, {value: option_value, text: option.text, displayed: 1}); + SelectBox.delete_from_cache(from, option_value); + } + } + SelectBox.redisplay(from); + SelectBox.redisplay(to); + }, + sort: function(id) { + SelectBox.cache[id].sort(function(a, b) { + a = a.text.toLowerCase(); + b = b.text.toLowerCase(); + if (a > b) { + return 1; + } + if (a < b) { + return -1; + } + return 0; + } ); + }, + select_all: function(id) { + const box = document.getElementById(id); + for (const option of box.options) { + option.selected = true; + } + } + }; + window.SelectBox = SelectBox; +} diff --git a/static/admin/js/SelectFilter2.js b/static/admin/js/SelectFilter2.js new file mode 100644 index 0000000..9a4e0a3 --- /dev/null +++ b/static/admin/js/SelectFilter2.js @@ -0,0 +1,283 @@ +/*global SelectBox, gettext, interpolate, quickElement, SelectFilter*/ +/* +SelectFilter2 - Turns a multiple-select box into a filter interface. + +Requires core.js and SelectBox.js. +*/ +'use strict'; +{ + window.SelectFilter = { + init: function(field_id, field_name, is_stacked) { + if (field_id.match(/__prefix__/)) { + // Don't initialize on empty forms. + return; + } + const from_box = document.getElementById(field_id); + from_box.id += '_from'; // change its ID + from_box.className = 'filtered'; + + for (const p of from_box.parentNode.getElementsByTagName('p')) { + if (p.classList.contains("info")) { + // Remove

, because it just gets in the way. + from_box.parentNode.removeChild(p); + } else if (p.classList.contains("help")) { + // Move help text up to the top so it isn't below the select + // boxes or wrapped off on the side to the right of the add + // button: + from_box.parentNode.insertBefore(p, from_box.parentNode.firstChild); + } + } + + //

or
+ const selector_div = quickElement('div', from_box.parentNode); + selector_div.className = is_stacked ? 'selector stacked' : 'selector'; + + //
+ const selector_available = quickElement('div', selector_div); + selector_available.className = 'selector-available'; + const title_available = quickElement('h2', selector_available, interpolate(gettext('Available %s') + ' ', [field_name])); + quickElement( + 'span', title_available, '', + 'class', 'help help-tooltip help-icon', + 'title', interpolate( + gettext( + 'This is the list of available %s. You may choose some by ' + + 'selecting them in the box below and then clicking the ' + + '"Choose" arrow between the two boxes.' + ), + [field_name] + ) + ); + + const filter_p = quickElement('p', selector_available, '', 'id', field_id + '_filter'); + filter_p.className = 'selector-filter'; + + const search_filter_label = quickElement('label', filter_p, '', 'for', field_id + '_input'); + + quickElement( + 'span', search_filter_label, '', + 'class', 'help-tooltip search-label-icon', + 'title', interpolate(gettext("Type into this box to filter down the list of available %s."), [field_name]) + ); + + filter_p.appendChild(document.createTextNode(' ')); + + const filter_input = quickElement('input', filter_p, '', 'type', 'text', 'placeholder', gettext("Filter")); + filter_input.id = field_id + '_input'; + + selector_available.appendChild(from_box); + const choose_all = quickElement('a', selector_available, gettext('Choose all'), 'title', interpolate(gettext('Click to choose all %s at once.'), [field_name]), 'href', '#', 'id', field_id + '_add_all_link'); + choose_all.className = 'selector-chooseall'; + + //
    + const selector_chooser = quickElement('ul', selector_div); + selector_chooser.className = 'selector-chooser'; + const add_link = quickElement('a', quickElement('li', selector_chooser), gettext('Choose'), 'title', gettext('Choose'), 'href', '#', 'id', field_id + '_add_link'); + add_link.className = 'selector-add'; + const remove_link = quickElement('a', quickElement('li', selector_chooser), gettext('Remove'), 'title', gettext('Remove'), 'href', '#', 'id', field_id + '_remove_link'); + remove_link.className = 'selector-remove'; + + //
    + const selector_chosen = quickElement('div', selector_div, '', 'id', field_id + '_selector_chosen'); + selector_chosen.className = 'selector-chosen'; + const title_chosen = quickElement('h2', selector_chosen, interpolate(gettext('Chosen %s') + ' ', [field_name])); + quickElement( + 'span', title_chosen, '', + 'class', 'help help-tooltip help-icon', + 'title', interpolate( + gettext( + 'This is the list of chosen %s. You may remove some by ' + + 'selecting them in the box below and then clicking the ' + + '"Remove" arrow between the two boxes.' + ), + [field_name] + ) + ); + + const filter_selected_p = quickElement('p', selector_chosen, '', 'id', field_id + '_filter_selected'); + filter_selected_p.className = 'selector-filter'; + + const search_filter_selected_label = quickElement('label', filter_selected_p, '', 'for', field_id + '_selected_input'); + + quickElement( + 'span', search_filter_selected_label, '', + 'class', 'help-tooltip search-label-icon', + 'title', interpolate(gettext("Type into this box to filter down the list of selected %s."), [field_name]) + ); + + filter_selected_p.appendChild(document.createTextNode(' ')); + + const filter_selected_input = quickElement('input', filter_selected_p, '', 'type', 'text', 'placeholder', gettext("Filter")); + filter_selected_input.id = field_id + '_selected_input'; + + const to_box = quickElement('select', selector_chosen, '', 'id', field_id + '_to', 'multiple', '', 'size', from_box.size, 'name', from_box.name); + to_box.className = 'filtered'; + + const warning_footer = quickElement('div', selector_chosen, '', 'class', 'list-footer-display'); + quickElement('span', warning_footer, '', 'id', field_id + '_list-footer-display-text'); + quickElement('span', warning_footer, ' (click to clear)', 'class', 'list-footer-display__clear'); + + const clear_all = quickElement('a', selector_chosen, gettext('Remove all'), 'title', interpolate(gettext('Click to remove all chosen %s at once.'), [field_name]), 'href', '#', 'id', field_id + '_remove_all_link'); + clear_all.className = 'selector-clearall'; + + from_box.name = from_box.name + '_old'; + + // Set up the JavaScript event handlers for the select box filter interface + const move_selection = function(e, elem, move_func, from, to) { + if (elem.classList.contains('active')) { + move_func(from, to); + SelectFilter.refresh_icons(field_id); + SelectFilter.refresh_filtered_selects(field_id); + SelectFilter.refresh_filtered_warning(field_id); + } + e.preventDefault(); + }; + choose_all.addEventListener('click', function(e) { + move_selection(e, this, SelectBox.move_all, field_id + '_from', field_id + '_to'); + }); + add_link.addEventListener('click', function(e) { + move_selection(e, this, SelectBox.move, field_id + '_from', field_id + '_to'); + }); + remove_link.addEventListener('click', function(e) { + move_selection(e, this, SelectBox.move, field_id + '_to', field_id + '_from'); + }); + clear_all.addEventListener('click', function(e) { + move_selection(e, this, SelectBox.move_all, field_id + '_to', field_id + '_from'); + }); + warning_footer.addEventListener('click', function(e) { + filter_selected_input.value = ''; + SelectBox.filter(field_id + '_to', ''); + SelectFilter.refresh_filtered_warning(field_id); + SelectFilter.refresh_icons(field_id); + }); + filter_input.addEventListener('keypress', function(e) { + SelectFilter.filter_key_press(e, field_id, '_from', '_to'); + }); + filter_input.addEventListener('keyup', function(e) { + SelectFilter.filter_key_up(e, field_id, '_from'); + }); + filter_input.addEventListener('keydown', function(e) { + SelectFilter.filter_key_down(e, field_id, '_from', '_to'); + }); + filter_selected_input.addEventListener('keypress', function(e) { + SelectFilter.filter_key_press(e, field_id, '_to', '_from'); + }); + filter_selected_input.addEventListener('keyup', function(e) { + SelectFilter.filter_key_up(e, field_id, '_to', '_selected_input'); + }); + filter_selected_input.addEventListener('keydown', function(e) { + SelectFilter.filter_key_down(e, field_id, '_to', '_from'); + }); + selector_div.addEventListener('change', function(e) { + if (e.target.tagName === 'SELECT') { + SelectFilter.refresh_icons(field_id); + } + }); + selector_div.addEventListener('dblclick', function(e) { + if (e.target.tagName === 'OPTION') { + if (e.target.closest('select').id === field_id + '_to') { + SelectBox.move(field_id + '_to', field_id + '_from'); + } else { + SelectBox.move(field_id + '_from', field_id + '_to'); + } + SelectFilter.refresh_icons(field_id); + } + }); + from_box.closest('form').addEventListener('submit', function() { + SelectBox.filter(field_id + '_to', ''); + SelectBox.select_all(field_id + '_to'); + }); + SelectBox.init(field_id + '_from'); + SelectBox.init(field_id + '_to'); + // Move selected from_box options to to_box + SelectBox.move(field_id + '_from', field_id + '_to'); + + // Initial icon refresh + SelectFilter.refresh_icons(field_id); + }, + any_selected: function(field) { + // Temporarily add the required attribute and check validity. + field.required = true; + const any_selected = field.checkValidity(); + field.required = false; + return any_selected; + }, + refresh_filtered_warning: function(field_id) { + const count = SelectBox.get_hidden_node_count(field_id + '_to'); + const selector = document.getElementById(field_id + '_selector_chosen'); + const warning = document.getElementById(field_id + '_list-footer-display-text'); + selector.className = selector.className.replace('selector-chosen--with-filtered', ''); + warning.textContent = interpolate(ngettext( + '%s selected option not visible', + '%s selected options not visible', + count + ), [count]); + if(count > 0) { + selector.className += ' selector-chosen--with-filtered'; + } + }, + refresh_filtered_selects: function(field_id) { + SelectBox.filter(field_id + '_from', document.getElementById(field_id + "_input").value); + SelectBox.filter(field_id + '_to', document.getElementById(field_id + "_selected_input").value); + }, + refresh_icons: function(field_id) { + const from = document.getElementById(field_id + '_from'); + const to = document.getElementById(field_id + '_to'); + // Active if at least one item is selected + document.getElementById(field_id + '_add_link').classList.toggle('active', SelectFilter.any_selected(from)); + document.getElementById(field_id + '_remove_link').classList.toggle('active', SelectFilter.any_selected(to)); + // Active if the corresponding box isn't empty + document.getElementById(field_id + '_add_all_link').classList.toggle('active', from.querySelector('option')); + document.getElementById(field_id + '_remove_all_link').classList.toggle('active', to.querySelector('option')); + SelectFilter.refresh_filtered_warning(field_id); + }, + filter_key_press: function(event, field_id, source, target) { + const source_box = document.getElementById(field_id + source); + // don't submit form if user pressed Enter + if ((event.which && event.which === 13) || (event.keyCode && event.keyCode === 13)) { + source_box.selectedIndex = 0; + SelectBox.move(field_id + source, field_id + target); + source_box.selectedIndex = 0; + event.preventDefault(); + } + }, + filter_key_up: function(event, field_id, source, filter_input) { + const input = filter_input || '_input'; + const source_box = document.getElementById(field_id + source); + const temp = source_box.selectedIndex; + SelectBox.filter(field_id + source, document.getElementById(field_id + input).value); + source_box.selectedIndex = temp; + SelectFilter.refresh_filtered_warning(field_id); + SelectFilter.refresh_icons(field_id); + }, + filter_key_down: function(event, field_id, source, target) { + const source_box = document.getElementById(field_id + source); + // right key (39) or left key (37) + const direction = source === '_from' ? 39 : 37; + // right arrow -- move across + if ((event.which && event.which === direction) || (event.keyCode && event.keyCode === direction)) { + const old_index = source_box.selectedIndex; + SelectBox.move(field_id + source, field_id + target); + SelectFilter.refresh_filtered_selects(field_id); + SelectFilter.refresh_filtered_warning(field_id); + source_box.selectedIndex = (old_index === source_box.length) ? source_box.length - 1 : old_index; + return; + } + // down arrow -- wrap around + if ((event.which && event.which === 40) || (event.keyCode && event.keyCode === 40)) { + source_box.selectedIndex = (source_box.length === source_box.selectedIndex + 1) ? 0 : source_box.selectedIndex + 1; + } + // up arrow -- wrap around + if ((event.which && event.which === 38) || (event.keyCode && event.keyCode === 38)) { + source_box.selectedIndex = (source_box.selectedIndex === 0) ? source_box.length - 1 : source_box.selectedIndex - 1; + } + } + }; + + window.addEventListener('load', function(e) { + document.querySelectorAll('select.selectfilter, select.selectfilterstacked').forEach(function(el) { + const data = el.dataset; + SelectFilter.init(el.id, data.fieldName, parseInt(data.isStacked, 10)); + }); + }); +} diff --git a/static/admin/js/actions.js b/static/admin/js/actions.js new file mode 100644 index 0000000..20a5c14 --- /dev/null +++ b/static/admin/js/actions.js @@ -0,0 +1,201 @@ +/*global gettext, interpolate, ngettext*/ +'use strict'; +{ + function show(selector) { + document.querySelectorAll(selector).forEach(function(el) { + el.classList.remove('hidden'); + }); + } + + function hide(selector) { + document.querySelectorAll(selector).forEach(function(el) { + el.classList.add('hidden'); + }); + } + + function showQuestion(options) { + hide(options.acrossClears); + show(options.acrossQuestions); + hide(options.allContainer); + } + + function showClear(options) { + show(options.acrossClears); + hide(options.acrossQuestions); + document.querySelector(options.actionContainer).classList.remove(options.selectedClass); + show(options.allContainer); + hide(options.counterContainer); + } + + function reset(options) { + hide(options.acrossClears); + hide(options.acrossQuestions); + hide(options.allContainer); + show(options.counterContainer); + } + + function clearAcross(options) { + reset(options); + const acrossInputs = document.querySelectorAll(options.acrossInput); + acrossInputs.forEach(function(acrossInput) { + acrossInput.value = 0; + }); + document.querySelector(options.actionContainer).classList.remove(options.selectedClass); + } + + function checker(actionCheckboxes, options, checked) { + if (checked) { + showQuestion(options); + } else { + reset(options); + } + actionCheckboxes.forEach(function(el) { + el.checked = checked; + el.closest('tr').classList.toggle(options.selectedClass, checked); + }); + } + + function updateCounter(actionCheckboxes, options) { + const sel = Array.from(actionCheckboxes).filter(function(el) { + return el.checked; + }).length; + const counter = document.querySelector(options.counterContainer); + // data-actions-icnt is defined in the generated HTML + // and contains the total amount of objects in the queryset + const actions_icnt = Number(counter.dataset.actionsIcnt); + counter.textContent = interpolate( + ngettext('%(sel)s of %(cnt)s selected', '%(sel)s of %(cnt)s selected', sel), { + sel: sel, + cnt: actions_icnt + }, true); + const allToggle = document.getElementById(options.allToggleId); + allToggle.checked = sel === actionCheckboxes.length; + if (allToggle.checked) { + showQuestion(options); + } else { + clearAcross(options); + } + } + + const defaults = { + actionContainer: "div.actions", + counterContainer: "span.action-counter", + allContainer: "div.actions span.all", + acrossInput: "div.actions input.select-across", + acrossQuestions: "div.actions span.question", + acrossClears: "div.actions span.clear", + allToggleId: "action-toggle", + selectedClass: "selected" + }; + + window.Actions = function(actionCheckboxes, options) { + options = Object.assign({}, defaults, options); + let list_editable_changed = false; + let lastChecked = null; + let shiftPressed = false; + + document.addEventListener('keydown', (event) => { + shiftPressed = event.shiftKey; + }); + + document.addEventListener('keyup', (event) => { + shiftPressed = event.shiftKey; + }); + + document.getElementById(options.allToggleId).addEventListener('click', function(event) { + checker(actionCheckboxes, options, this.checked); + updateCounter(actionCheckboxes, options); + }); + + document.querySelectorAll(options.acrossQuestions + " a").forEach(function(el) { + el.addEventListener('click', function(event) { + event.preventDefault(); + const acrossInputs = document.querySelectorAll(options.acrossInput); + acrossInputs.forEach(function(acrossInput) { + acrossInput.value = 1; + }); + showClear(options); + }); + }); + + document.querySelectorAll(options.acrossClears + " a").forEach(function(el) { + el.addEventListener('click', function(event) { + event.preventDefault(); + document.getElementById(options.allToggleId).checked = false; + clearAcross(options); + checker(actionCheckboxes, options, false); + updateCounter(actionCheckboxes, options); + }); + }); + + function affectedCheckboxes(target, withModifier) { + const multiSelect = (lastChecked && withModifier && lastChecked !== target); + if (!multiSelect) { + return [target]; + } + const checkboxes = Array.from(actionCheckboxes); + const targetIndex = checkboxes.findIndex(el => el === target); + const lastCheckedIndex = checkboxes.findIndex(el => el === lastChecked); + const startIndex = Math.min(targetIndex, lastCheckedIndex); + const endIndex = Math.max(targetIndex, lastCheckedIndex); + const filtered = checkboxes.filter((el, index) => (startIndex <= index) && (index <= endIndex)); + return filtered; + }; + + Array.from(document.getElementById('result_list').tBodies).forEach(function(el) { + el.addEventListener('change', function(event) { + const target = event.target; + if (target.classList.contains('action-select')) { + const checkboxes = affectedCheckboxes(target, shiftPressed); + checker(checkboxes, options, target.checked); + updateCounter(actionCheckboxes, options); + lastChecked = target; + } else { + list_editable_changed = true; + } + }); + }); + + document.querySelector('#changelist-form button[name=index]').addEventListener('click', function(event) { + if (list_editable_changed) { + const confirmed = confirm(gettext("You have unsaved changes on individual editable fields. If you run an action, your unsaved changes will be lost.")); + if (!confirmed) { + event.preventDefault(); + } + } + }); + + const el = document.querySelector('#changelist-form input[name=_save]'); + // The button does not exist if no fields are editable. + if (el) { + el.addEventListener('click', function(event) { + if (document.querySelector('[name=action]').value) { + const text = list_editable_changed + ? gettext("You have selected an action, but you haven’t saved your changes to individual fields yet. Please click OK to save. You’ll need to re-run the action.") + : gettext("You have selected an action, and you haven’t made any changes on individual fields. You’re probably looking for the Go button rather than the Save button."); + if (!confirm(text)) { + event.preventDefault(); + } + } + }); + } + }; + + // Call function fn when the DOM is loaded and ready. If it is already + // loaded, call the function now. + // http://youmightnotneedjquery.com/#ready + function ready(fn) { + if (document.readyState !== 'loading') { + fn(); + } else { + document.addEventListener('DOMContentLoaded', fn); + } + } + + ready(function() { + const actionsEls = document.querySelectorAll('tr input.action-select'); + if (actionsEls.length > 0) { + Actions(actionsEls); + } + }); +} diff --git a/static/admin/js/admin/DateTimeShortcuts.js b/static/admin/js/admin/DateTimeShortcuts.js new file mode 100644 index 0000000..aa1cae9 --- /dev/null +++ b/static/admin/js/admin/DateTimeShortcuts.js @@ -0,0 +1,408 @@ +/*global Calendar, findPosX, findPosY, get_format, gettext, gettext_noop, interpolate, ngettext, quickElement*/ +// Inserts shortcut buttons after all of the following: +// +// +'use strict'; +{ + const DateTimeShortcuts = { + calendars: [], + calendarInputs: [], + clockInputs: [], + clockHours: { + default_: [ + [gettext_noop('Now'), -1], + [gettext_noop('Midnight'), 0], + [gettext_noop('6 a.m.'), 6], + [gettext_noop('Noon'), 12], + [gettext_noop('6 p.m.'), 18] + ] + }, + dismissClockFunc: [], + dismissCalendarFunc: [], + calendarDivName1: 'calendarbox', // name of calendar
    that gets toggled + calendarDivName2: 'calendarin', // name of
    that contains calendar + calendarLinkName: 'calendarlink', // name of the link that is used to toggle + clockDivName: 'clockbox', // name of clock
    that gets toggled + clockLinkName: 'clocklink', // name of the link that is used to toggle + shortCutsClass: 'datetimeshortcuts', // class of the clock and cal shortcuts + timezoneWarningClass: 'timezonewarning', // class of the warning for timezone mismatch + timezoneOffset: 0, + init: function() { + const serverOffset = document.body.dataset.adminUtcOffset; + if (serverOffset) { + const localOffset = new Date().getTimezoneOffset() * -60; + DateTimeShortcuts.timezoneOffset = localOffset - serverOffset; + } + + for (const inp of document.getElementsByTagName('input')) { + if (inp.type === 'text' && inp.classList.contains('vTimeField')) { + DateTimeShortcuts.addClock(inp); + DateTimeShortcuts.addTimezoneWarning(inp); + } + else if (inp.type === 'text' && inp.classList.contains('vDateField')) { + DateTimeShortcuts.addCalendar(inp); + DateTimeShortcuts.addTimezoneWarning(inp); + } + } + }, + // Return the current time while accounting for the server timezone. + now: function() { + const serverOffset = document.body.dataset.adminUtcOffset; + if (serverOffset) { + const localNow = new Date(); + const localOffset = localNow.getTimezoneOffset() * -60; + localNow.setTime(localNow.getTime() + 1000 * (serverOffset - localOffset)); + return localNow; + } else { + return new Date(); + } + }, + // Add a warning when the time zone in the browser and backend do not match. + addTimezoneWarning: function(inp) { + const warningClass = DateTimeShortcuts.timezoneWarningClass; + let timezoneOffset = DateTimeShortcuts.timezoneOffset / 3600; + + // Only warn if there is a time zone mismatch. + if (!timezoneOffset) { + return; + } + + // Check if warning is already there. + if (inp.parentNode.querySelectorAll('.' + warningClass).length) { + return; + } + + let message; + if (timezoneOffset > 0) { + message = ngettext( + 'Note: You are %s hour ahead of server time.', + 'Note: You are %s hours ahead of server time.', + timezoneOffset + ); + } + else { + timezoneOffset *= -1; + message = ngettext( + 'Note: You are %s hour behind server time.', + 'Note: You are %s hours behind server time.', + timezoneOffset + ); + } + message = interpolate(message, [timezoneOffset]); + + const warning = document.createElement('div'); + warning.classList.add('help', warningClass); + warning.textContent = message; + inp.parentNode.appendChild(warning); + }, + // Add clock widget to a given field + addClock: function(inp) { + const num = DateTimeShortcuts.clockInputs.length; + DateTimeShortcuts.clockInputs[num] = inp; + DateTimeShortcuts.dismissClockFunc[num] = function() { DateTimeShortcuts.dismissClock(num); return true; }; + + // Shortcut links (clock icon and "Now" link) + const shortcuts_span = document.createElement('span'); + shortcuts_span.className = DateTimeShortcuts.shortCutsClass; + inp.parentNode.insertBefore(shortcuts_span, inp.nextSibling); + const now_link = document.createElement('a'); + now_link.href = "#"; + now_link.textContent = gettext('Now'); + now_link.addEventListener('click', function(e) { + e.preventDefault(); + DateTimeShortcuts.handleClockQuicklink(num, -1); + }); + const clock_link = document.createElement('a'); + clock_link.href = '#'; + clock_link.id = DateTimeShortcuts.clockLinkName + num; + clock_link.addEventListener('click', function(e) { + e.preventDefault(); + // avoid triggering the document click handler to dismiss the clock + e.stopPropagation(); + DateTimeShortcuts.openClock(num); + }); + + quickElement( + 'span', clock_link, '', + 'class', 'clock-icon', + 'title', gettext('Choose a Time') + ); + shortcuts_span.appendChild(document.createTextNode('\u00A0')); + shortcuts_span.appendChild(now_link); + shortcuts_span.appendChild(document.createTextNode('\u00A0|\u00A0')); + shortcuts_span.appendChild(clock_link); + + // Create clock link div + // + // Markup looks like: + //
    + //

    Choose a time

    + // + //

    Cancel

    + //
    + + const clock_box = document.createElement('div'); + clock_box.style.display = 'none'; + clock_box.style.position = 'absolute'; + clock_box.className = 'clockbox module'; + clock_box.id = DateTimeShortcuts.clockDivName + num; + document.body.appendChild(clock_box); + clock_box.addEventListener('click', function(e) { e.stopPropagation(); }); + + quickElement('h2', clock_box, gettext('Choose a time')); + const time_list = quickElement('ul', clock_box); + time_list.className = 'timelist'; + // The list of choices can be overridden in JavaScript like this: + // DateTimeShortcuts.clockHours.name = [['3 a.m.', 3]]; + // where name is the name attribute of the . + const name = typeof DateTimeShortcuts.clockHours[inp.name] === 'undefined' ? 'default_' : inp.name; + DateTimeShortcuts.clockHours[name].forEach(function(element) { + const time_link = quickElement('a', quickElement('li', time_list), gettext(element[0]), 'href', '#'); + time_link.addEventListener('click', function(e) { + e.preventDefault(); + DateTimeShortcuts.handleClockQuicklink(num, element[1]); + }); + }); + + const cancel_p = quickElement('p', clock_box); + cancel_p.className = 'calendar-cancel'; + const cancel_link = quickElement('a', cancel_p, gettext('Cancel'), 'href', '#'); + cancel_link.addEventListener('click', function(e) { + e.preventDefault(); + DateTimeShortcuts.dismissClock(num); + }); + + document.addEventListener('keyup', function(event) { + if (event.which === 27) { + // ESC key closes popup + DateTimeShortcuts.dismissClock(num); + event.preventDefault(); + } + }); + }, + openClock: function(num) { + const clock_box = document.getElementById(DateTimeShortcuts.clockDivName + num); + const clock_link = document.getElementById(DateTimeShortcuts.clockLinkName + num); + + // Recalculate the clockbox position + // is it left-to-right or right-to-left layout ? + if (window.getComputedStyle(document.body).direction !== 'rtl') { + clock_box.style.left = findPosX(clock_link) + 17 + 'px'; + } + else { + // since style's width is in em, it'd be tough to calculate + // px value of it. let's use an estimated px for now + clock_box.style.left = findPosX(clock_link) - 110 + 'px'; + } + clock_box.style.top = Math.max(0, findPosY(clock_link) - 30) + 'px'; + + // Show the clock box + clock_box.style.display = 'block'; + document.addEventListener('click', DateTimeShortcuts.dismissClockFunc[num]); + }, + dismissClock: function(num) { + document.getElementById(DateTimeShortcuts.clockDivName + num).style.display = 'none'; + document.removeEventListener('click', DateTimeShortcuts.dismissClockFunc[num]); + }, + handleClockQuicklink: function(num, val) { + let d; + if (val === -1) { + d = DateTimeShortcuts.now(); + } + else { + d = new Date(1970, 1, 1, val, 0, 0, 0); + } + DateTimeShortcuts.clockInputs[num].value = d.strftime(get_format('TIME_INPUT_FORMATS')[0]); + DateTimeShortcuts.clockInputs[num].focus(); + DateTimeShortcuts.dismissClock(num); + }, + // Add calendar widget to a given field. + addCalendar: function(inp) { + const num = DateTimeShortcuts.calendars.length; + + DateTimeShortcuts.calendarInputs[num] = inp; + DateTimeShortcuts.dismissCalendarFunc[num] = function() { DateTimeShortcuts.dismissCalendar(num); return true; }; + + // Shortcut links (calendar icon and "Today" link) + const shortcuts_span = document.createElement('span'); + shortcuts_span.className = DateTimeShortcuts.shortCutsClass; + inp.parentNode.insertBefore(shortcuts_span, inp.nextSibling); + const today_link = document.createElement('a'); + today_link.href = '#'; + today_link.appendChild(document.createTextNode(gettext('Today'))); + today_link.addEventListener('click', function(e) { + e.preventDefault(); + DateTimeShortcuts.handleCalendarQuickLink(num, 0); + }); + const cal_link = document.createElement('a'); + cal_link.href = '#'; + cal_link.id = DateTimeShortcuts.calendarLinkName + num; + cal_link.addEventListener('click', function(e) { + e.preventDefault(); + // avoid triggering the document click handler to dismiss the calendar + e.stopPropagation(); + DateTimeShortcuts.openCalendar(num); + }); + quickElement( + 'span', cal_link, '', + 'class', 'date-icon', + 'title', gettext('Choose a Date') + ); + shortcuts_span.appendChild(document.createTextNode('\u00A0')); + shortcuts_span.appendChild(today_link); + shortcuts_span.appendChild(document.createTextNode('\u00A0|\u00A0')); + shortcuts_span.appendChild(cal_link); + + // Create calendarbox div. + // + // Markup looks like: + // + //
    + //

    + // + // February 2003 + //

    + //
    + // + //
    + //
    + // Yesterday | Today | Tomorrow + //
    + //

    Cancel

    + //
    + const cal_box = document.createElement('div'); + cal_box.style.display = 'none'; + cal_box.style.position = 'absolute'; + cal_box.className = 'calendarbox module'; + cal_box.id = DateTimeShortcuts.calendarDivName1 + num; + document.body.appendChild(cal_box); + cal_box.addEventListener('click', function(e) { e.stopPropagation(); }); + + // next-prev links + const cal_nav = quickElement('div', cal_box); + const cal_nav_prev = quickElement('a', cal_nav, '<', 'href', '#'); + cal_nav_prev.className = 'calendarnav-previous'; + cal_nav_prev.addEventListener('click', function(e) { + e.preventDefault(); + DateTimeShortcuts.drawPrev(num); + }); + + const cal_nav_next = quickElement('a', cal_nav, '>', 'href', '#'); + cal_nav_next.className = 'calendarnav-next'; + cal_nav_next.addEventListener('click', function(e) { + e.preventDefault(); + DateTimeShortcuts.drawNext(num); + }); + + // main box + const cal_main = quickElement('div', cal_box, '', 'id', DateTimeShortcuts.calendarDivName2 + num); + cal_main.className = 'calendar'; + DateTimeShortcuts.calendars[num] = new Calendar(DateTimeShortcuts.calendarDivName2 + num, DateTimeShortcuts.handleCalendarCallback(num)); + DateTimeShortcuts.calendars[num].drawCurrent(); + + // calendar shortcuts + const shortcuts = quickElement('div', cal_box); + shortcuts.className = 'calendar-shortcuts'; + let day_link = quickElement('a', shortcuts, gettext('Yesterday'), 'href', '#'); + day_link.addEventListener('click', function(e) { + e.preventDefault(); + DateTimeShortcuts.handleCalendarQuickLink(num, -1); + }); + shortcuts.appendChild(document.createTextNode('\u00A0|\u00A0')); + day_link = quickElement('a', shortcuts, gettext('Today'), 'href', '#'); + day_link.addEventListener('click', function(e) { + e.preventDefault(); + DateTimeShortcuts.handleCalendarQuickLink(num, 0); + }); + shortcuts.appendChild(document.createTextNode('\u00A0|\u00A0')); + day_link = quickElement('a', shortcuts, gettext('Tomorrow'), 'href', '#'); + day_link.addEventListener('click', function(e) { + e.preventDefault(); + DateTimeShortcuts.handleCalendarQuickLink(num, +1); + }); + + // cancel bar + const cancel_p = quickElement('p', cal_box); + cancel_p.className = 'calendar-cancel'; + const cancel_link = quickElement('a', cancel_p, gettext('Cancel'), 'href', '#'); + cancel_link.addEventListener('click', function(e) { + e.preventDefault(); + DateTimeShortcuts.dismissCalendar(num); + }); + document.addEventListener('keyup', function(event) { + if (event.which === 27) { + // ESC key closes popup + DateTimeShortcuts.dismissCalendar(num); + event.preventDefault(); + } + }); + }, + openCalendar: function(num) { + const cal_box = document.getElementById(DateTimeShortcuts.calendarDivName1 + num); + const cal_link = document.getElementById(DateTimeShortcuts.calendarLinkName + num); + const inp = DateTimeShortcuts.calendarInputs[num]; + + // Determine if the current value in the input has a valid date. + // If so, draw the calendar with that date's year and month. + if (inp.value) { + const format = get_format('DATE_INPUT_FORMATS')[0]; + const selected = inp.value.strptime(format); + const year = selected.getUTCFullYear(); + const month = selected.getUTCMonth() + 1; + const re = /\d{4}/; + if (re.test(year.toString()) && month >= 1 && month <= 12) { + DateTimeShortcuts.calendars[num].drawDate(month, year, selected); + } + } + + // Recalculate the clockbox position + // is it left-to-right or right-to-left layout ? + if (window.getComputedStyle(document.body).direction !== 'rtl') { + cal_box.style.left = findPosX(cal_link) + 17 + 'px'; + } + else { + // since style's width is in em, it'd be tough to calculate + // px value of it. let's use an estimated px for now + cal_box.style.left = findPosX(cal_link) - 180 + 'px'; + } + cal_box.style.top = Math.max(0, findPosY(cal_link) - 75) + 'px'; + + cal_box.style.display = 'block'; + document.addEventListener('click', DateTimeShortcuts.dismissCalendarFunc[num]); + }, + dismissCalendar: function(num) { + document.getElementById(DateTimeShortcuts.calendarDivName1 + num).style.display = 'none'; + document.removeEventListener('click', DateTimeShortcuts.dismissCalendarFunc[num]); + }, + drawPrev: function(num) { + DateTimeShortcuts.calendars[num].drawPreviousMonth(); + }, + drawNext: function(num) { + DateTimeShortcuts.calendars[num].drawNextMonth(); + }, + handleCalendarCallback: function(num) { + const format = get_format('DATE_INPUT_FORMATS')[0]; + return function(y, m, d) { + DateTimeShortcuts.calendarInputs[num].value = new Date(y, m - 1, d).strftime(format); + DateTimeShortcuts.calendarInputs[num].focus(); + document.getElementById(DateTimeShortcuts.calendarDivName1 + num).style.display = 'none'; + }; + }, + handleCalendarQuickLink: function(num, offset) { + const d = DateTimeShortcuts.now(); + d.setDate(d.getDate() + offset); + DateTimeShortcuts.calendarInputs[num].value = d.strftime(get_format('DATE_INPUT_FORMATS')[0]); + DateTimeShortcuts.calendarInputs[num].focus(); + DateTimeShortcuts.dismissCalendar(num); + } + }; + + window.addEventListener('load', DateTimeShortcuts.init); + window.DateTimeShortcuts = DateTimeShortcuts; +} diff --git a/static/admin/js/admin/RelatedObjectLookups.js b/static/admin/js/admin/RelatedObjectLookups.js new file mode 100644 index 0000000..afb6b66 --- /dev/null +++ b/static/admin/js/admin/RelatedObjectLookups.js @@ -0,0 +1,238 @@ +/*global SelectBox, interpolate*/ +// Handles related-objects functionality: lookup link for raw_id_fields +// and Add Another links. +'use strict'; +{ + const $ = django.jQuery; + let popupIndex = 0; + const relatedWindows = []; + + function dismissChildPopups() { + relatedWindows.forEach(function(win) { + if(!win.closed) { + win.dismissChildPopups(); + win.close(); + } + }); + } + + function setPopupIndex() { + if(document.getElementsByName("_popup").length > 0) { + const index = window.name.lastIndexOf("__") + 2; + popupIndex = parseInt(window.name.substring(index)); + } else { + popupIndex = 0; + } + } + + function addPopupIndex(name) { + return name + "__" + (popupIndex + 1); + } + + function removePopupIndex(name) { + return name.replace(new RegExp("__" + (popupIndex + 1) + "$"), ''); + } + + function showAdminPopup(triggeringLink, name_regexp, add_popup) { + const name = addPopupIndex(triggeringLink.id.replace(name_regexp, '')); + const href = new URL(triggeringLink.href); + if (add_popup) { + href.searchParams.set('_popup', 1); + } + const win = window.open(href, name, 'height=500,width=800,resizable=yes,scrollbars=yes'); + relatedWindows.push(win); + win.focus(); + return false; + } + + function showRelatedObjectLookupPopup(triggeringLink) { + return showAdminPopup(triggeringLink, /^lookup_/, true); + } + + function dismissRelatedLookupPopup(win, chosenId) { + const name = removePopupIndex(win.name); + const elem = document.getElementById(name); + if (elem.classList.contains('vManyToManyRawIdAdminField') && elem.value) { + elem.value += ',' + chosenId; + } else { + document.getElementById(name).value = chosenId; + } + const index = relatedWindows.indexOf(win); + if (index > -1) { + relatedWindows.splice(index, 1); + } + win.close(); + } + + function showRelatedObjectPopup(triggeringLink) { + return showAdminPopup(triggeringLink, /^(change|add|delete)_/, false); + } + + function updateRelatedObjectLinks(triggeringLink) { + const $this = $(triggeringLink); + const siblings = $this.nextAll('.view-related, .change-related, .delete-related'); + if (!siblings.length) { + return; + } + const value = $this.val(); + if (value) { + siblings.each(function() { + const elm = $(this); + elm.attr('href', elm.attr('data-href-template').replace('__fk__', value)); + }); + } else { + siblings.removeAttr('href'); + } + } + + function updateRelatedSelectsOptions(currentSelect, win, objId, newRepr, newId) { + // After create/edit a model from the options next to the current + // select (+ or :pencil:) update ForeignKey PK of the rest of selects + // in the page. + + const path = win.location.pathname; + // Extract the model from the popup url '...//add/' or + // '...///change/' depending the action (add or change). + const modelName = path.split('/')[path.split('/').length - (objId ? 4 : 3)]; + // Exclude autocomplete selects. + const selectsRelated = document.querySelectorAll(`[data-model-ref="${modelName}"] select:not(.admin-autocomplete)`); + + selectsRelated.forEach(function(select) { + if (currentSelect === select) { + return; + } + + let option = select.querySelector(`option[value="${objId}"]`); + + if (!option) { + option = new Option(newRepr, newId); + select.options.add(option); + return; + } + + option.textContent = newRepr; + option.value = newId; + }); + } + + function dismissAddRelatedObjectPopup(win, newId, newRepr) { + const name = removePopupIndex(win.name); + const elem = document.getElementById(name); + if (elem) { + const elemName = elem.nodeName.toUpperCase(); + if (elemName === 'SELECT') { + elem.options[elem.options.length] = new Option(newRepr, newId, true, true); + updateRelatedSelectsOptions(elem, win, null, newRepr, newId); + } else if (elemName === 'INPUT') { + if (elem.classList.contains('vManyToManyRawIdAdminField') && elem.value) { + elem.value += ',' + newId; + } else { + elem.value = newId; + } + } + // Trigger a change event to update related links if required. + $(elem).trigger('change'); + } else { + const toId = name + "_to"; + const o = new Option(newRepr, newId); + SelectBox.add_to_cache(toId, o); + SelectBox.redisplay(toId); + } + const index = relatedWindows.indexOf(win); + if (index > -1) { + relatedWindows.splice(index, 1); + } + win.close(); + } + + function dismissChangeRelatedObjectPopup(win, objId, newRepr, newId) { + const id = removePopupIndex(win.name.replace(/^edit_/, '')); + const selectsSelector = interpolate('#%s, #%s_from, #%s_to', [id, id, id]); + const selects = $(selectsSelector); + selects.find('option').each(function() { + if (this.value === objId) { + this.textContent = newRepr; + this.value = newId; + } + }).trigger('change'); + updateRelatedSelectsOptions(selects[0], win, objId, newRepr, newId); + selects.next().find('.select2-selection__rendered').each(function() { + // The element can have a clear button as a child. + // Use the lastChild to modify only the displayed value. + this.lastChild.textContent = newRepr; + this.title = newRepr; + }); + const index = relatedWindows.indexOf(win); + if (index > -1) { + relatedWindows.splice(index, 1); + } + win.close(); + } + + function dismissDeleteRelatedObjectPopup(win, objId) { + const id = removePopupIndex(win.name.replace(/^delete_/, '')); + const selectsSelector = interpolate('#%s, #%s_from, #%s_to', [id, id, id]); + const selects = $(selectsSelector); + selects.find('option').each(function() { + if (this.value === objId) { + $(this).remove(); + } + }).trigger('change'); + const index = relatedWindows.indexOf(win); + if (index > -1) { + relatedWindows.splice(index, 1); + } + win.close(); + } + + window.showRelatedObjectLookupPopup = showRelatedObjectLookupPopup; + window.dismissRelatedLookupPopup = dismissRelatedLookupPopup; + window.showRelatedObjectPopup = showRelatedObjectPopup; + window.updateRelatedObjectLinks = updateRelatedObjectLinks; + window.dismissAddRelatedObjectPopup = dismissAddRelatedObjectPopup; + window.dismissChangeRelatedObjectPopup = dismissChangeRelatedObjectPopup; + window.dismissDeleteRelatedObjectPopup = dismissDeleteRelatedObjectPopup; + window.dismissChildPopups = dismissChildPopups; + + // Kept for backward compatibility + window.showAddAnotherPopup = showRelatedObjectPopup; + window.dismissAddAnotherPopup = dismissAddRelatedObjectPopup; + + window.addEventListener('unload', function(evt) { + window.dismissChildPopups(); + }); + + $(document).ready(function() { + setPopupIndex(); + $("a[data-popup-opener]").on('click', function(event) { + event.preventDefault(); + opener.dismissRelatedLookupPopup(window, $(this).data("popup-opener")); + }); + $('body').on('click', '.related-widget-wrapper-link[data-popup="yes"]', function(e) { + e.preventDefault(); + if (this.href) { + const event = $.Event('django:show-related', {href: this.href}); + $(this).trigger(event); + if (!event.isDefaultPrevented()) { + showRelatedObjectPopup(this); + } + } + }); + $('body').on('change', '.related-widget-wrapper select', function(e) { + const event = $.Event('django:update-related'); + $(this).trigger(event); + if (!event.isDefaultPrevented()) { + updateRelatedObjectLinks(this); + } + }); + $('.related-widget-wrapper select').trigger('change'); + $('body').on('click', '.related-lookup', function(e) { + e.preventDefault(); + const event = $.Event('django:lookup-related'); + $(this).trigger(event); + if (!event.isDefaultPrevented()) { + showRelatedObjectLookupPopup(this); + } + }); + }); +} diff --git a/static/admin/js/autocomplete.js b/static/admin/js/autocomplete.js new file mode 100644 index 0000000..d3daeab --- /dev/null +++ b/static/admin/js/autocomplete.js @@ -0,0 +1,33 @@ +'use strict'; +{ + const $ = django.jQuery; + + $.fn.djangoAdminSelect2 = function() { + $.each(this, function(i, element) { + $(element).select2({ + ajax: { + data: (params) => { + return { + term: params.term, + page: params.page, + app_label: element.dataset.appLabel, + model_name: element.dataset.modelName, + field_name: element.dataset.fieldName + }; + } + } + }); + }); + return this; + }; + + $(function() { + // Initialize all autocomplete widgets except the one in the template + // form used when a new formset is added. + $('.admin-autocomplete').not('[name*=__prefix__]').djangoAdminSelect2(); + }); + + document.addEventListener('formset:added', (event) => { + $(event.target).find('.admin-autocomplete').djangoAdminSelect2(); + }); +} diff --git a/static/admin/js/calendar.js b/static/admin/js/calendar.js new file mode 100644 index 0000000..a62d10a --- /dev/null +++ b/static/admin/js/calendar.js @@ -0,0 +1,221 @@ +/*global gettext, pgettext, get_format, quickElement, removeChildren*/ +/* +calendar.js - Calendar functions by Adrian Holovaty +depends on core.js for utility functions like removeChildren or quickElement +*/ +'use strict'; +{ + // CalendarNamespace -- Provides a collection of HTML calendar-related helper functions + const CalendarNamespace = { + monthsOfYear: [ + gettext('January'), + gettext('February'), + gettext('March'), + gettext('April'), + gettext('May'), + gettext('June'), + gettext('July'), + gettext('August'), + gettext('September'), + gettext('October'), + gettext('November'), + gettext('December') + ], + monthsOfYearAbbrev: [ + pgettext('abbrev. month January', 'Jan'), + pgettext('abbrev. month February', 'Feb'), + pgettext('abbrev. month March', 'Mar'), + pgettext('abbrev. month April', 'Apr'), + pgettext('abbrev. month May', 'May'), + pgettext('abbrev. month June', 'Jun'), + pgettext('abbrev. month July', 'Jul'), + pgettext('abbrev. month August', 'Aug'), + pgettext('abbrev. month September', 'Sep'), + pgettext('abbrev. month October', 'Oct'), + pgettext('abbrev. month November', 'Nov'), + pgettext('abbrev. month December', 'Dec') + ], + daysOfWeek: [ + pgettext('one letter Sunday', 'S'), + pgettext('one letter Monday', 'M'), + pgettext('one letter Tuesday', 'T'), + pgettext('one letter Wednesday', 'W'), + pgettext('one letter Thursday', 'T'), + pgettext('one letter Friday', 'F'), + pgettext('one letter Saturday', 'S') + ], + firstDayOfWeek: parseInt(get_format('FIRST_DAY_OF_WEEK')), + isLeapYear: function(year) { + return (((year % 4) === 0) && ((year % 100) !== 0 ) || ((year % 400) === 0)); + }, + getDaysInMonth: function(month, year) { + let days; + if (month === 1 || month === 3 || month === 5 || month === 7 || month === 8 || month === 10 || month === 12) { + days = 31; + } + else if (month === 4 || month === 6 || month === 9 || month === 11) { + days = 30; + } + else if (month === 2 && CalendarNamespace.isLeapYear(year)) { + days = 29; + } + else { + days = 28; + } + return days; + }, + draw: function(month, year, div_id, callback, selected) { // month = 1-12, year = 1-9999 + const today = new Date(); + const todayDay = today.getDate(); + const todayMonth = today.getMonth() + 1; + const todayYear = today.getFullYear(); + let todayClass = ''; + + // Use UTC functions here because the date field does not contain time + // and using the UTC function variants prevent the local time offset + // from altering the date, specifically the day field. For example: + // + // ``` + // var x = new Date('2013-10-02'); + // var day = x.getDate(); + // ``` + // + // The day variable above will be 1 instead of 2 in, say, US Pacific time + // zone. + let isSelectedMonth = false; + if (typeof selected !== 'undefined') { + isSelectedMonth = (selected.getUTCFullYear() === year && (selected.getUTCMonth() + 1) === month); + } + + month = parseInt(month); + year = parseInt(year); + const calDiv = document.getElementById(div_id); + removeChildren(calDiv); + const calTable = document.createElement('table'); + quickElement('caption', calTable, CalendarNamespace.monthsOfYear[month - 1] + ' ' + year); + const tableBody = quickElement('tbody', calTable); + + // Draw days-of-week header + let tableRow = quickElement('tr', tableBody); + for (let i = 0; i < 7; i++) { + quickElement('th', tableRow, CalendarNamespace.daysOfWeek[(i + CalendarNamespace.firstDayOfWeek) % 7]); + } + + const startingPos = new Date(year, month - 1, 1 - CalendarNamespace.firstDayOfWeek).getDay(); + const days = CalendarNamespace.getDaysInMonth(month, year); + + let nonDayCell; + + // Draw blanks before first of month + tableRow = quickElement('tr', tableBody); + for (let i = 0; i < startingPos; i++) { + nonDayCell = quickElement('td', tableRow, ' '); + nonDayCell.className = "nonday"; + } + + function calendarMonth(y, m) { + function onClick(e) { + e.preventDefault(); + callback(y, m, this.textContent); + } + return onClick; + } + + // Draw days of month + let currentDay = 1; + for (let i = startingPos; currentDay <= days; i++) { + if (i % 7 === 0 && currentDay !== 1) { + tableRow = quickElement('tr', tableBody); + } + if ((currentDay === todayDay) && (month === todayMonth) && (year === todayYear)) { + todayClass = 'today'; + } else { + todayClass = ''; + } + + // use UTC function; see above for explanation. + if (isSelectedMonth && currentDay === selected.getUTCDate()) { + if (todayClass !== '') { + todayClass += " "; + } + todayClass += "selected"; + } + + const cell = quickElement('td', tableRow, '', 'class', todayClass); + const link = quickElement('a', cell, currentDay, 'href', '#'); + link.addEventListener('click', calendarMonth(year, month)); + currentDay++; + } + + // Draw blanks after end of month (optional, but makes for valid code) + while (tableRow.childNodes.length < 7) { + nonDayCell = quickElement('td', tableRow, ' '); + nonDayCell.className = "nonday"; + } + + calDiv.appendChild(calTable); + } + }; + + // Calendar -- A calendar instance + function Calendar(div_id, callback, selected) { + // div_id (string) is the ID of the element in which the calendar will + // be displayed + // callback (string) is the name of a JavaScript function that will be + // called with the parameters (year, month, day) when a day in the + // calendar is clicked + this.div_id = div_id; + this.callback = callback; + this.today = new Date(); + this.currentMonth = this.today.getMonth() + 1; + this.currentYear = this.today.getFullYear(); + if (typeof selected !== 'undefined') { + this.selected = selected; + } + } + Calendar.prototype = { + drawCurrent: function() { + CalendarNamespace.draw(this.currentMonth, this.currentYear, this.div_id, this.callback, this.selected); + }, + drawDate: function(month, year, selected) { + this.currentMonth = month; + this.currentYear = year; + + if(selected) { + this.selected = selected; + } + + this.drawCurrent(); + }, + drawPreviousMonth: function() { + if (this.currentMonth === 1) { + this.currentMonth = 12; + this.currentYear--; + } + else { + this.currentMonth--; + } + this.drawCurrent(); + }, + drawNextMonth: function() { + if (this.currentMonth === 12) { + this.currentMonth = 1; + this.currentYear++; + } + else { + this.currentMonth++; + } + this.drawCurrent(); + }, + drawPreviousYear: function() { + this.currentYear--; + this.drawCurrent(); + }, + drawNextYear: function() { + this.currentYear++; + this.drawCurrent(); + } + }; + window.Calendar = Calendar; + window.CalendarNamespace = CalendarNamespace; +} diff --git a/static/admin/js/cancel.js b/static/admin/js/cancel.js new file mode 100644 index 0000000..3069c6f --- /dev/null +++ b/static/admin/js/cancel.js @@ -0,0 +1,29 @@ +'use strict'; +{ + // Call function fn when the DOM is loaded and ready. If it is already + // loaded, call the function now. + // http://youmightnotneedjquery.com/#ready + function ready(fn) { + if (document.readyState !== 'loading') { + fn(); + } else { + document.addEventListener('DOMContentLoaded', fn); + } + } + + ready(function() { + function handleClick(event) { + event.preventDefault(); + const params = new URLSearchParams(window.location.search); + if (params.has('_popup')) { + window.close(); // Close the popup. + } else { + window.history.back(); // Otherwise, go back. + } + } + + document.querySelectorAll('.cancel-link').forEach(function(el) { + el.addEventListener('click', handleClick); + }); + }); +} diff --git a/static/admin/js/change_form.js b/static/admin/js/change_form.js new file mode 100644 index 0000000..96a4c62 --- /dev/null +++ b/static/admin/js/change_form.js @@ -0,0 +1,16 @@ +'use strict'; +{ + const inputTags = ['BUTTON', 'INPUT', 'SELECT', 'TEXTAREA']; + const modelName = document.getElementById('django-admin-form-add-constants').dataset.modelName; + if (modelName) { + const form = document.getElementById(modelName + '_form'); + for (const element of form.elements) { + // HTMLElement.offsetParent returns null when the element is not + // rendered. + if (inputTags.includes(element.tagName) && !element.disabled && element.offsetParent) { + element.focus(); + break; + } + } + } +} diff --git a/static/admin/js/collapse.js b/static/admin/js/collapse.js new file mode 100644 index 0000000..c6c7b0f --- /dev/null +++ b/static/admin/js/collapse.js @@ -0,0 +1,43 @@ +/*global gettext*/ +'use strict'; +{ + window.addEventListener('load', function() { + // Add anchor tag for Show/Hide link + const fieldsets = document.querySelectorAll('fieldset.collapse'); + for (const [i, elem] of fieldsets.entries()) { + // Don't hide if fields in this fieldset have errors + if (elem.querySelectorAll('div.errors, ul.errorlist').length === 0) { + elem.classList.add('collapsed'); + const h2 = elem.querySelector('h2'); + const link = document.createElement('a'); + link.id = 'fieldsetcollapser' + i; + link.className = 'collapse-toggle'; + link.href = '#'; + link.textContent = gettext('Show'); + h2.appendChild(document.createTextNode(' (')); + h2.appendChild(link); + h2.appendChild(document.createTextNode(')')); + } + } + // Add toggle to hide/show anchor tag + const toggleFunc = function(ev) { + if (ev.target.matches('.collapse-toggle')) { + ev.preventDefault(); + ev.stopPropagation(); + const fieldset = ev.target.closest('fieldset'); + if (fieldset.classList.contains('collapsed')) { + // Show + ev.target.textContent = gettext('Hide'); + fieldset.classList.remove('collapsed'); + } else { + // Hide + ev.target.textContent = gettext('Show'); + fieldset.classList.add('collapsed'); + } + } + }; + document.querySelectorAll('fieldset.module').forEach(function(el) { + el.addEventListener('click', toggleFunc); + }); + }); +} diff --git a/static/admin/js/core.js b/static/admin/js/core.js new file mode 100644 index 0000000..0344a13 --- /dev/null +++ b/static/admin/js/core.js @@ -0,0 +1,170 @@ +// Core JavaScript helper functions +'use strict'; + +// quickElement(tagType, parentReference [, textInChildNode, attribute, attributeValue ...]); +function quickElement() { + const obj = document.createElement(arguments[0]); + if (arguments[2]) { + const textNode = document.createTextNode(arguments[2]); + obj.appendChild(textNode); + } + const len = arguments.length; + for (let i = 3; i < len; i += 2) { + obj.setAttribute(arguments[i], arguments[i + 1]); + } + arguments[1].appendChild(obj); + return obj; +} + +// "a" is reference to an object +function removeChildren(a) { + while (a.hasChildNodes()) { + a.removeChild(a.lastChild); + } +} + +// ---------------------------------------------------------------------------- +// Find-position functions by PPK +// See https://www.quirksmode.org/js/findpos.html +// ---------------------------------------------------------------------------- +function findPosX(obj) { + let curleft = 0; + if (obj.offsetParent) { + while (obj.offsetParent) { + curleft += obj.offsetLeft - obj.scrollLeft; + obj = obj.offsetParent; + } + } else if (obj.x) { + curleft += obj.x; + } + return curleft; +} + +function findPosY(obj) { + let curtop = 0; + if (obj.offsetParent) { + while (obj.offsetParent) { + curtop += obj.offsetTop - obj.scrollTop; + obj = obj.offsetParent; + } + } else if (obj.y) { + curtop += obj.y; + } + return curtop; +} + +//----------------------------------------------------------------------------- +// Date object extensions +// ---------------------------------------------------------------------------- +{ + Date.prototype.getTwelveHours = function() { + return this.getHours() % 12 || 12; + }; + + Date.prototype.getTwoDigitMonth = function() { + return (this.getMonth() < 9) ? '0' + (this.getMonth() + 1) : (this.getMonth() + 1); + }; + + Date.prototype.getTwoDigitDate = function() { + return (this.getDate() < 10) ? '0' + this.getDate() : this.getDate(); + }; + + Date.prototype.getTwoDigitTwelveHour = function() { + return (this.getTwelveHours() < 10) ? '0' + this.getTwelveHours() : this.getTwelveHours(); + }; + + Date.prototype.getTwoDigitHour = function() { + return (this.getHours() < 10) ? '0' + this.getHours() : this.getHours(); + }; + + Date.prototype.getTwoDigitMinute = function() { + return (this.getMinutes() < 10) ? '0' + this.getMinutes() : this.getMinutes(); + }; + + Date.prototype.getTwoDigitSecond = function() { + return (this.getSeconds() < 10) ? '0' + this.getSeconds() : this.getSeconds(); + }; + + Date.prototype.getAbbrevMonthName = function() { + return typeof window.CalendarNamespace === "undefined" + ? this.getTwoDigitMonth() + : window.CalendarNamespace.monthsOfYearAbbrev[this.getMonth()]; + }; + + Date.prototype.getFullMonthName = function() { + return typeof window.CalendarNamespace === "undefined" + ? this.getTwoDigitMonth() + : window.CalendarNamespace.monthsOfYear[this.getMonth()]; + }; + + Date.prototype.strftime = function(format) { + const fields = { + b: this.getAbbrevMonthName(), + B: this.getFullMonthName(), + c: this.toString(), + d: this.getTwoDigitDate(), + H: this.getTwoDigitHour(), + I: this.getTwoDigitTwelveHour(), + m: this.getTwoDigitMonth(), + M: this.getTwoDigitMinute(), + p: (this.getHours() >= 12) ? 'PM' : 'AM', + S: this.getTwoDigitSecond(), + w: '0' + this.getDay(), + x: this.toLocaleDateString(), + X: this.toLocaleTimeString(), + y: ('' + this.getFullYear()).substr(2, 4), + Y: '' + this.getFullYear(), + '%': '%' + }; + let result = '', i = 0; + while (i < format.length) { + if (format.charAt(i) === '%') { + result += fields[format.charAt(i + 1)]; + ++i; + } + else { + result += format.charAt(i); + } + ++i; + } + return result; + }; + + // ---------------------------------------------------------------------------- + // String object extensions + // ---------------------------------------------------------------------------- + String.prototype.strptime = function(format) { + const split_format = format.split(/[.\-/]/); + const date = this.split(/[.\-/]/); + let i = 0; + let day, month, year; + while (i < split_format.length) { + switch (split_format[i]) { + case "%d": + day = date[i]; + break; + case "%m": + month = date[i] - 1; + break; + case "%Y": + year = date[i]; + break; + case "%y": + // A %y value in the range of [00, 68] is in the current + // century, while [69, 99] is in the previous century, + // according to the Open Group Specification. + if (parseInt(date[i], 10) >= 69) { + year = date[i]; + } else { + year = (new Date(Date.UTC(date[i], 0))).getUTCFullYear() + 100; + } + break; + } + ++i; + } + // Create Date object from UTC since the parsed value is supposed to be + // in UTC, not local time. Also, the calendar uses UTC functions for + // date extraction. + return new Date(Date.UTC(year, month, day)); + }; +} diff --git a/static/admin/js/filters.js b/static/admin/js/filters.js new file mode 100644 index 0000000..f5536eb --- /dev/null +++ b/static/admin/js/filters.js @@ -0,0 +1,30 @@ +/** + * Persist changelist filters state (collapsed/expanded). + */ +'use strict'; +{ + // Init filters. + let filters = JSON.parse(sessionStorage.getItem('django.admin.filtersState')); + + if (!filters) { + filters = {}; + } + + Object.entries(filters).forEach(([key, value]) => { + const detailElement = document.querySelector(`[data-filter-title='${CSS.escape(key)}']`); + + // Check if the filter is present, it could be from other view. + if (detailElement) { + value ? detailElement.setAttribute('open', '') : detailElement.removeAttribute('open'); + } + }); + + // Save filter state when clicks. + const details = document.querySelectorAll('details'); + details.forEach(detail => { + detail.addEventListener('toggle', event => { + filters[`${event.target.dataset.filterTitle}`] = detail.open; + sessionStorage.setItem('django.admin.filtersState', JSON.stringify(filters)); + }); + }); +} diff --git a/static/admin/js/inlines.js b/static/admin/js/inlines.js new file mode 100644 index 0000000..e9a1dfe --- /dev/null +++ b/static/admin/js/inlines.js @@ -0,0 +1,359 @@ +/*global DateTimeShortcuts, SelectFilter*/ +/** + * Django admin inlines + * + * Based on jQuery Formset 1.1 + * @author Stanislaus Madueke (stan DOT madueke AT gmail DOT com) + * @requires jQuery 1.2.6 or later + * + * Copyright (c) 2009, Stanislaus Madueke + * All rights reserved. + * + * Spiced up with Code from Zain Memon's GSoC project 2009 + * and modified for Django by Jannis Leidel, Travis Swicegood and Julien Phalip. + * + * Licensed under the New BSD License + * See: https://opensource.org/licenses/bsd-license.php + */ +'use strict'; +{ + const $ = django.jQuery; + $.fn.formset = function(opts) { + const options = $.extend({}, $.fn.formset.defaults, opts); + const $this = $(this); + const $parent = $this.parent(); + const updateElementIndex = function(el, prefix, ndx) { + const id_regex = new RegExp("(" + prefix + "-(\\d+|__prefix__))"); + const replacement = prefix + "-" + ndx; + if ($(el).prop("for")) { + $(el).prop("for", $(el).prop("for").replace(id_regex, replacement)); + } + if (el.id) { + el.id = el.id.replace(id_regex, replacement); + } + if (el.name) { + el.name = el.name.replace(id_regex, replacement); + } + }; + const totalForms = $("#id_" + options.prefix + "-TOTAL_FORMS").prop("autocomplete", "off"); + let nextIndex = parseInt(totalForms.val(), 10); + const maxForms = $("#id_" + options.prefix + "-MAX_NUM_FORMS").prop("autocomplete", "off"); + const minForms = $("#id_" + options.prefix + "-MIN_NUM_FORMS").prop("autocomplete", "off"); + let addButton; + + /** + * The "Add another MyModel" button below the inline forms. + */ + const addInlineAddButton = function() { + if (addButton === null) { + if ($this.prop("tagName") === "TR") { + // If forms are laid out as table rows, insert the + // "add" button in a new table row: + const numCols = $this.eq(-1).children().length; + $parent.append('' + options.addText + ""); + addButton = $parent.find("tr:last a"); + } else { + // Otherwise, insert it immediately after the last form: + $this.filter(":last").after('"); + addButton = $this.filter(":last").next().find("a"); + } + } + addButton.on('click', addInlineClickHandler); + }; + + const addInlineClickHandler = function(e) { + e.preventDefault(); + const template = $("#" + options.prefix + "-empty"); + const row = template.clone(true); + row.removeClass(options.emptyCssClass) + .addClass(options.formCssClass) + .attr("id", options.prefix + "-" + nextIndex); + addInlineDeleteButton(row); + row.find("*").each(function() { + updateElementIndex(this, options.prefix, totalForms.val()); + }); + // Insert the new form when it has been fully edited. + row.insertBefore($(template)); + // Update number of total forms. + $(totalForms).val(parseInt(totalForms.val(), 10) + 1); + nextIndex += 1; + // Hide the add button if there's a limit and it's been reached. + if ((maxForms.val() !== '') && (maxForms.val() - totalForms.val()) <= 0) { + addButton.parent().hide(); + } + // Show the remove buttons if there are more than min_num. + toggleDeleteButtonVisibility(row.closest('.inline-group')); + + // Pass the new form to the post-add callback, if provided. + if (options.added) { + options.added(row); + } + row.get(0).dispatchEvent(new CustomEvent("formset:added", { + bubbles: true, + detail: { + formsetName: options.prefix + } + })); + }; + + /** + * The "X" button that is part of every unsaved inline. + * (When saved, it is replaced with a "Delete" checkbox.) + */ + const addInlineDeleteButton = function(row) { + if (row.is("tr")) { + // If the forms are laid out in table rows, insert + // the remove button into the last table cell: + row.children(":last").append('"); + } else if (row.is("ul") || row.is("ol")) { + // If they're laid out as an ordered/unordered list, + // insert an
  • after the last list item: + row.append('
  • ' + options.deleteText + "
  • "); + } else { + // Otherwise, just insert the remove button as the + // last child element of the form's container: + row.children(":first").append('' + options.deleteText + ""); + } + // Add delete handler for each row. + row.find("a." + options.deleteCssClass).on('click', inlineDeleteHandler.bind(this)); + }; + + const inlineDeleteHandler = function(e1) { + e1.preventDefault(); + const deleteButton = $(e1.target); + const row = deleteButton.closest('.' + options.formCssClass); + const inlineGroup = row.closest('.inline-group'); + // Remove the parent form containing this button, + // and also remove the relevant row with non-field errors: + const prevRow = row.prev(); + if (prevRow.length && prevRow.hasClass('row-form-errors')) { + prevRow.remove(); + } + row.remove(); + nextIndex -= 1; + // Pass the deleted form to the post-delete callback, if provided. + if (options.removed) { + options.removed(row); + } + document.dispatchEvent(new CustomEvent("formset:removed", { + detail: { + formsetName: options.prefix + } + })); + // Update the TOTAL_FORMS form count. + const forms = $("." + options.formCssClass); + $("#id_" + options.prefix + "-TOTAL_FORMS").val(forms.length); + // Show add button again once below maximum number. + if ((maxForms.val() === '') || (maxForms.val() - forms.length) > 0) { + addButton.parent().show(); + } + // Hide the remove buttons if at min_num. + toggleDeleteButtonVisibility(inlineGroup); + // Also, update names and ids for all remaining form controls so + // they remain in sequence: + let i, formCount; + const updateElementCallback = function() { + updateElementIndex(this, options.prefix, i); + }; + for (i = 0, formCount = forms.length; i < formCount; i++) { + updateElementIndex($(forms).get(i), options.prefix, i); + $(forms.get(i)).find("*").each(updateElementCallback); + } + }; + + const toggleDeleteButtonVisibility = function(inlineGroup) { + if ((minForms.val() !== '') && (minForms.val() - totalForms.val()) >= 0) { + inlineGroup.find('.inline-deletelink').hide(); + } else { + inlineGroup.find('.inline-deletelink').show(); + } + }; + + $this.each(function(i) { + $(this).not("." + options.emptyCssClass).addClass(options.formCssClass); + }); + + // Create the delete buttons for all unsaved inlines: + $this.filter('.' + options.formCssClass + ':not(.has_original):not(.' + options.emptyCssClass + ')').each(function() { + addInlineDeleteButton($(this)); + }); + toggleDeleteButtonVisibility($this); + + // Create the add button, initially hidden. + addButton = options.addButton; + addInlineAddButton(); + + // Show the add button if allowed to add more items. + // Note that max_num = None translates to a blank string. + const showAddButton = maxForms.val() === '' || (maxForms.val() - totalForms.val()) > 0; + if ($this.length && showAddButton) { + addButton.parent().show(); + } else { + addButton.parent().hide(); + } + + return this; + }; + + /* Setup plugin defaults */ + $.fn.formset.defaults = { + prefix: "form", // The form prefix for your django formset + addText: "add another", // Text for the add link + deleteText: "remove", // Text for the delete link + addCssClass: "add-row", // CSS class applied to the add link + deleteCssClass: "delete-row", // CSS class applied to the delete link + emptyCssClass: "empty-row", // CSS class applied to the empty row + formCssClass: "dynamic-form", // CSS class applied to each form in a formset + added: null, // Function called each time a new form is added + removed: null, // Function called each time a form is deleted + addButton: null // Existing add button to use + }; + + + // Tabular inlines --------------------------------------------------------- + $.fn.tabularFormset = function(selector, options) { + const $rows = $(this); + + const reinitDateTimeShortCuts = function() { + // Reinitialize the calendar and clock widgets by force + if (typeof DateTimeShortcuts !== "undefined") { + $(".datetimeshortcuts").remove(); + DateTimeShortcuts.init(); + } + }; + + const updateSelectFilter = function() { + // If any SelectFilter widgets are a part of the new form, + // instantiate a new SelectFilter instance for it. + if (typeof SelectFilter !== 'undefined') { + $('.selectfilter').each(function(index, value) { + SelectFilter.init(value.id, this.dataset.fieldName, false); + }); + $('.selectfilterstacked').each(function(index, value) { + SelectFilter.init(value.id, this.dataset.fieldName, true); + }); + } + }; + + const initPrepopulatedFields = function(row) { + row.find('.prepopulated_field').each(function() { + const field = $(this), + input = field.find('input, select, textarea'), + dependency_list = input.data('dependency_list') || [], + dependencies = []; + $.each(dependency_list, function(i, field_name) { + dependencies.push('#' + row.find('.field-' + field_name).find('input, select, textarea').attr('id')); + }); + if (dependencies.length) { + input.prepopulate(dependencies, input.attr('maxlength')); + } + }); + }; + + $rows.formset({ + prefix: options.prefix, + addText: options.addText, + formCssClass: "dynamic-" + options.prefix, + deleteCssClass: "inline-deletelink", + deleteText: options.deleteText, + emptyCssClass: "empty-form", + added: function(row) { + initPrepopulatedFields(row); + reinitDateTimeShortCuts(); + updateSelectFilter(); + }, + addButton: options.addButton + }); + + return $rows; + }; + + // Stacked inlines --------------------------------------------------------- + $.fn.stackedFormset = function(selector, options) { + const $rows = $(this); + const updateInlineLabel = function(row) { + $(selector).find(".inline_label").each(function(i) { + const count = i + 1; + $(this).html($(this).html().replace(/(#\d+)/g, "#" + count)); + }); + }; + + const reinitDateTimeShortCuts = function() { + // Reinitialize the calendar and clock widgets by force, yuck. + if (typeof DateTimeShortcuts !== "undefined") { + $(".datetimeshortcuts").remove(); + DateTimeShortcuts.init(); + } + }; + + const updateSelectFilter = function() { + // If any SelectFilter widgets were added, instantiate a new instance. + if (typeof SelectFilter !== "undefined") { + $(".selectfilter").each(function(index, value) { + SelectFilter.init(value.id, this.dataset.fieldName, false); + }); + $(".selectfilterstacked").each(function(index, value) { + SelectFilter.init(value.id, this.dataset.fieldName, true); + }); + } + }; + + const initPrepopulatedFields = function(row) { + row.find('.prepopulated_field').each(function() { + const field = $(this), + input = field.find('input, select, textarea'), + dependency_list = input.data('dependency_list') || [], + dependencies = []; + $.each(dependency_list, function(i, field_name) { + // Dependency in a fieldset. + let field_element = row.find('.form-row .field-' + field_name); + // Dependency without a fieldset. + if (!field_element.length) { + field_element = row.find('.form-row.field-' + field_name); + } + dependencies.push('#' + field_element.find('input, select, textarea').attr('id')); + }); + if (dependencies.length) { + input.prepopulate(dependencies, input.attr('maxlength')); + } + }); + }; + + $rows.formset({ + prefix: options.prefix, + addText: options.addText, + formCssClass: "dynamic-" + options.prefix, + deleteCssClass: "inline-deletelink", + deleteText: options.deleteText, + emptyCssClass: "empty-form", + removed: updateInlineLabel, + added: function(row) { + initPrepopulatedFields(row); + reinitDateTimeShortCuts(); + updateSelectFilter(); + updateInlineLabel(row); + }, + addButton: options.addButton + }); + + return $rows; + }; + + $(document).ready(function() { + $(".js-inline-admin-formset").each(function() { + const data = $(this).data(), + inlineOptions = data.inlineFormset; + let selector; + switch(data.inlineType) { + case "stacked": + selector = inlineOptions.name + "-group .inline-related"; + $(selector).stackedFormset(selector, inlineOptions.options); + break; + case "tabular": + selector = inlineOptions.name + "-group .tabular.inline-related tbody:first > tr.form-row"; + $(selector).tabularFormset(selector, inlineOptions.options); + break; + } + }); + }); +} diff --git a/static/admin/js/jquery.init.js b/static/admin/js/jquery.init.js new file mode 100644 index 0000000..f40b27f --- /dev/null +++ b/static/admin/js/jquery.init.js @@ -0,0 +1,8 @@ +/*global jQuery:false*/ +'use strict'; +/* Puts the included jQuery into our own namespace using noConflict and passing + * it 'true'. This ensures that the included jQuery doesn't pollute the global + * namespace (i.e. this preserves pre-existing values for both window.$ and + * window.jQuery). + */ +window.django = {jQuery: jQuery.noConflict(true)}; diff --git a/static/admin/js/nav_sidebar.js b/static/admin/js/nav_sidebar.js new file mode 100644 index 0000000..7e735db --- /dev/null +++ b/static/admin/js/nav_sidebar.js @@ -0,0 +1,79 @@ +'use strict'; +{ + const toggleNavSidebar = document.getElementById('toggle-nav-sidebar'); + if (toggleNavSidebar !== null) { + const navSidebar = document.getElementById('nav-sidebar'); + const main = document.getElementById('main'); + let navSidebarIsOpen = localStorage.getItem('django.admin.navSidebarIsOpen'); + if (navSidebarIsOpen === null) { + navSidebarIsOpen = 'true'; + } + main.classList.toggle('shifted', navSidebarIsOpen === 'true'); + navSidebar.setAttribute('aria-expanded', navSidebarIsOpen); + + toggleNavSidebar.addEventListener('click', function() { + if (navSidebarIsOpen === 'true') { + navSidebarIsOpen = 'false'; + } else { + navSidebarIsOpen = 'true'; + } + localStorage.setItem('django.admin.navSidebarIsOpen', navSidebarIsOpen); + main.classList.toggle('shifted'); + navSidebar.setAttribute('aria-expanded', navSidebarIsOpen); + }); + } + + function initSidebarQuickFilter() { + const options = []; + const navSidebar = document.getElementById('nav-sidebar'); + if (!navSidebar) { + return; + } + navSidebar.querySelectorAll('th[scope=row] a').forEach((container) => { + options.push({title: container.innerHTML, node: container}); + }); + + function checkValue(event) { + let filterValue = event.target.value; + if (filterValue) { + filterValue = filterValue.toLowerCase(); + } + if (event.key === 'Escape') { + filterValue = ''; + event.target.value = ''; // clear input + } + let matches = false; + for (const o of options) { + let displayValue = ''; + if (filterValue) { + if (o.title.toLowerCase().indexOf(filterValue) === -1) { + displayValue = 'none'; + } else { + matches = true; + } + } + // show/hide parent + o.node.parentNode.parentNode.style.display = displayValue; + } + if (!filterValue || matches) { + event.target.classList.remove('no-results'); + } else { + event.target.classList.add('no-results'); + } + sessionStorage.setItem('django.admin.navSidebarFilterValue', filterValue); + } + + const nav = document.getElementById('nav-filter'); + nav.addEventListener('change', checkValue, false); + nav.addEventListener('input', checkValue, false); + nav.addEventListener('keyup', checkValue, false); + + const storedValue = sessionStorage.getItem('django.admin.navSidebarFilterValue'); + if (storedValue) { + nav.value = storedValue; + checkValue({target: nav, key: ''}); + } + } + window.initSidebarQuickFilter = initSidebarQuickFilter; + initSidebarQuickFilter(); +} diff --git a/static/admin/js/popup_response.js b/static/admin/js/popup_response.js new file mode 100644 index 0000000..2b1d3dd --- /dev/null +++ b/static/admin/js/popup_response.js @@ -0,0 +1,16 @@ +/*global opener */ +'use strict'; +{ + const initData = JSON.parse(document.getElementById('django-admin-popup-response-constants').dataset.popupResponse); + switch(initData.action) { + case 'change': + opener.dismissChangeRelatedObjectPopup(window, initData.value, initData.obj, initData.new_value); + break; + case 'delete': + opener.dismissDeleteRelatedObjectPopup(window, initData.value); + break; + default: + opener.dismissAddRelatedObjectPopup(window, initData.value, initData.obj); + break; + } +} diff --git a/static/admin/js/prepopulate.js b/static/admin/js/prepopulate.js new file mode 100644 index 0000000..89e95ab --- /dev/null +++ b/static/admin/js/prepopulate.js @@ -0,0 +1,43 @@ +/*global URLify*/ +'use strict'; +{ + const $ = django.jQuery; + $.fn.prepopulate = function(dependencies, maxLength, allowUnicode) { + /* + Depends on urlify.js + Populates a selected field with the values of the dependent fields, + URLifies and shortens the string. + dependencies - array of dependent fields ids + maxLength - maximum length of the URLify'd string + allowUnicode - Unicode support of the URLify'd string + */ + return this.each(function() { + const prepopulatedField = $(this); + + const populate = function() { + // Bail if the field's value has been changed by the user + if (prepopulatedField.data('_changed')) { + return; + } + + const values = []; + $.each(dependencies, function(i, field) { + field = $(field); + if (field.val().length > 0) { + values.push(field.val()); + } + }); + prepopulatedField.val(URLify(values.join(' '), maxLength, allowUnicode)); + }; + + prepopulatedField.data('_changed', false); + prepopulatedField.on('change', function() { + prepopulatedField.data('_changed', true); + }); + + if (!prepopulatedField.val()) { + $(dependencies.join(',')).on('keyup change focus', populate); + } + }); + }; +} diff --git a/static/admin/js/prepopulate_init.js b/static/admin/js/prepopulate_init.js new file mode 100644 index 0000000..a58841f --- /dev/null +++ b/static/admin/js/prepopulate_init.js @@ -0,0 +1,15 @@ +'use strict'; +{ + const $ = django.jQuery; + const fields = $('#django-admin-prepopulated-fields-constants').data('prepopulatedFields'); + $.each(fields, function(index, field) { + $( + '.empty-form .form-row .field-' + field.name + + ', .empty-form.form-row .field-' + field.name + + ', .empty-form .form-row.field-' + field.name + ).addClass('prepopulated_field'); + $(field.id).data('dependency_list', field.dependency_list).prepopulate( + field.dependency_ids, field.maxLength, field.allowUnicode + ); + }); +} diff --git a/static/admin/js/theme.js b/static/admin/js/theme.js new file mode 100644 index 0000000..794cd15 --- /dev/null +++ b/static/admin/js/theme.js @@ -0,0 +1,56 @@ +'use strict'; +{ + window.addEventListener('load', function(e) { + + function setTheme(mode) { + if (mode !== "light" && mode !== "dark" && mode !== "auto") { + console.error(`Got invalid theme mode: ${mode}. Resetting to auto.`); + mode = "auto"; + } + document.documentElement.dataset.theme = mode; + localStorage.setItem("theme", mode); + } + + function cycleTheme() { + const currentTheme = localStorage.getItem("theme") || "auto"; + const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches; + + if (prefersDark) { + // Auto (dark) -> Light -> Dark + if (currentTheme === "auto") { + setTheme("light"); + } else if (currentTheme === "light") { + setTheme("dark"); + } else { + setTheme("auto"); + } + } else { + // Auto (light) -> Dark -> Light + if (currentTheme === "auto") { + setTheme("dark"); + } else if (currentTheme === "dark") { + setTheme("light"); + } else { + setTheme("auto"); + } + } + } + + function initTheme() { + // set theme defined in localStorage if there is one, or fallback to auto mode + const currentTheme = localStorage.getItem("theme"); + currentTheme ? setTheme(currentTheme) : setTheme("auto"); + } + + function setupTheme() { + // Attach event handlers for toggling themes + const buttons = document.getElementsByClassName("theme-toggle"); + Array.from(buttons).forEach((btn) => { + btn.addEventListener("click", cycleTheme); + }); + initTheme(); + } + + setupTheme(); + }); +} diff --git a/static/admin/js/urlify.js b/static/admin/js/urlify.js new file mode 100644 index 0000000..9fc0409 --- /dev/null +++ b/static/admin/js/urlify.js @@ -0,0 +1,169 @@ +/*global XRegExp*/ +'use strict'; +{ + const LATIN_MAP = { + 'À': 'A', 'Á': 'A', 'Â': 'A', 'Ã': 'A', 'Ä': 'A', 'Å': 'A', 'Æ': 'AE', + 'Ç': 'C', 'È': 'E', 'É': 'E', 'Ê': 'E', 'Ë': 'E', 'Ì': 'I', 'Í': 'I', + 'Î': 'I', 'Ï': 'I', 'Ð': 'D', 'Ñ': 'N', 'Ò': 'O', 'Ó': 'O', 'Ô': 'O', + 'Õ': 'O', 'Ö': 'O', 'Ő': 'O', 'Ø': 'O', 'Ù': 'U', 'Ú': 'U', 'Û': 'U', + 'Ü': 'U', 'Ű': 'U', 'Ý': 'Y', 'Þ': 'TH', 'Ÿ': 'Y', 'ß': 'ss', 'à': 'a', + 'á': 'a', 'â': 'a', 'ã': 'a', 'ä': 'a', 'å': 'a', 'æ': 'ae', 'ç': 'c', + 'è': 'e', 'é': 'e', 'ê': 'e', 'ë': 'e', 'ì': 'i', 'í': 'i', 'î': 'i', + 'ï': 'i', 'ð': 'd', 'ñ': 'n', 'ò': 'o', 'ó': 'o', 'ô': 'o', 'õ': 'o', + 'ö': 'o', 'ő': 'o', 'ø': 'o', 'ù': 'u', 'ú': 'u', 'û': 'u', 'ü': 'u', + 'ű': 'u', 'ý': 'y', 'þ': 'th', 'ÿ': 'y' + }; + const LATIN_SYMBOLS_MAP = { + '©': '(c)' + }; + const GREEK_MAP = { + 'α': 'a', 'β': 'b', 'γ': 'g', 'δ': 'd', 'ε': 'e', 'ζ': 'z', 'η': 'h', + 'θ': '8', 'ι': 'i', 'κ': 'k', 'λ': 'l', 'μ': 'm', 'ν': 'n', 'ξ': '3', + 'ο': 'o', 'π': 'p', 'ρ': 'r', 'σ': 's', 'τ': 't', 'υ': 'y', 'φ': 'f', + 'χ': 'x', 'ψ': 'ps', 'ω': 'w', 'ά': 'a', 'έ': 'e', 'ί': 'i', 'ό': 'o', + 'ύ': 'y', 'ή': 'h', 'ώ': 'w', 'ς': 's', 'ϊ': 'i', 'ΰ': 'y', 'ϋ': 'y', + 'ΐ': 'i', 'Α': 'A', 'Β': 'B', 'Γ': 'G', 'Δ': 'D', 'Ε': 'E', 'Ζ': 'Z', + 'Η': 'H', 'Θ': '8', 'Ι': 'I', 'Κ': 'K', 'Λ': 'L', 'Μ': 'M', 'Ν': 'N', + 'Ξ': '3', 'Ο': 'O', 'Π': 'P', 'Ρ': 'R', 'Σ': 'S', 'Τ': 'T', 'Υ': 'Y', + 'Φ': 'F', 'Χ': 'X', 'Ψ': 'PS', 'Ω': 'W', 'Ά': 'A', 'Έ': 'E', 'Ί': 'I', + 'Ό': 'O', 'Ύ': 'Y', 'Ή': 'H', 'Ώ': 'W', 'Ϊ': 'I', 'Ϋ': 'Y' + }; + const TURKISH_MAP = { + 'ş': 's', 'Ş': 'S', 'ı': 'i', 'İ': 'I', 'ç': 'c', 'Ç': 'C', 'ü': 'u', + 'Ü': 'U', 'ö': 'o', 'Ö': 'O', 'ğ': 'g', 'Ğ': 'G' + }; + const ROMANIAN_MAP = { + 'ă': 'a', 'î': 'i', 'ș': 's', 'ț': 't', 'â': 'a', + 'Ă': 'A', 'Î': 'I', 'Ș': 'S', 'Ț': 'T', 'Â': 'A' + }; + const RUSSIAN_MAP = { + 'а': 'a', 'б': 'b', 'в': 'v', 'г': 'g', 'д': 'd', 'е': 'e', 'ё': 'yo', + 'ж': 'zh', 'з': 'z', 'и': 'i', 'й': 'j', 'к': 'k', 'л': 'l', 'м': 'm', + 'н': 'n', 'о': 'o', 'п': 'p', 'р': 'r', 'с': 's', 'т': 't', 'у': 'u', + 'ф': 'f', 'х': 'h', 'ц': 'c', 'ч': 'ch', 'ш': 'sh', 'щ': 'sh', 'ъ': '', + 'ы': 'y', 'ь': '', 'э': 'e', 'ю': 'yu', 'я': 'ya', + 'А': 'A', 'Б': 'B', 'В': 'V', 'Г': 'G', 'Д': 'D', 'Е': 'E', 'Ё': 'Yo', + 'Ж': 'Zh', 'З': 'Z', 'И': 'I', 'Й': 'J', 'К': 'K', 'Л': 'L', 'М': 'M', + 'Н': 'N', 'О': 'O', 'П': 'P', 'Р': 'R', 'С': 'S', 'Т': 'T', 'У': 'U', + 'Ф': 'F', 'Х': 'H', 'Ц': 'C', 'Ч': 'Ch', 'Ш': 'Sh', 'Щ': 'Sh', 'Ъ': '', + 'Ы': 'Y', 'Ь': '', 'Э': 'E', 'Ю': 'Yu', 'Я': 'Ya' + }; + const UKRAINIAN_MAP = { + 'Є': 'Ye', 'І': 'I', 'Ї': 'Yi', 'Ґ': 'G', 'є': 'ye', 'і': 'i', + 'ї': 'yi', 'ґ': 'g' + }; + const CZECH_MAP = { + 'č': 'c', 'ď': 'd', 'ě': 'e', 'ň': 'n', 'ř': 'r', 'š': 's', 'ť': 't', + 'ů': 'u', 'ž': 'z', 'Č': 'C', 'Ď': 'D', 'Ě': 'E', 'Ň': 'N', 'Ř': 'R', + 'Š': 'S', 'Ť': 'T', 'Ů': 'U', 'Ž': 'Z' + }; + const SLOVAK_MAP = { + 'á': 'a', 'ä': 'a', 'č': 'c', 'ď': 'd', 'é': 'e', 'í': 'i', 'ľ': 'l', + 'ĺ': 'l', 'ň': 'n', 'ó': 'o', 'ô': 'o', 'ŕ': 'r', 'š': 's', 'ť': 't', + 'ú': 'u', 'ý': 'y', 'ž': 'z', + 'Á': 'a', 'Ä': 'A', 'Č': 'C', 'Ď': 'D', 'É': 'E', 'Í': 'I', 'Ľ': 'L', + 'Ĺ': 'L', 'Ň': 'N', 'Ó': 'O', 'Ô': 'O', 'Ŕ': 'R', 'Š': 'S', 'Ť': 'T', + 'Ú': 'U', 'Ý': 'Y', 'Ž': 'Z' + }; + const POLISH_MAP = { + 'ą': 'a', 'ć': 'c', 'ę': 'e', 'ł': 'l', 'ń': 'n', 'ó': 'o', 'ś': 's', + 'ź': 'z', 'ż': 'z', + 'Ą': 'A', 'Ć': 'C', 'Ę': 'E', 'Ł': 'L', 'Ń': 'N', 'Ó': 'O', 'Ś': 'S', + 'Ź': 'Z', 'Ż': 'Z' + }; + const LATVIAN_MAP = { + 'ā': 'a', 'č': 'c', 'ē': 'e', 'ģ': 'g', 'ī': 'i', 'ķ': 'k', 'ļ': 'l', + 'ņ': 'n', 'š': 's', 'ū': 'u', 'ž': 'z', + 'Ā': 'A', 'Č': 'C', 'Ē': 'E', 'Ģ': 'G', 'Ī': 'I', 'Ķ': 'K', 'Ļ': 'L', + 'Ņ': 'N', 'Š': 'S', 'Ū': 'U', 'Ž': 'Z' + }; + const ARABIC_MAP = { + 'أ': 'a', 'ب': 'b', 'ت': 't', 'ث': 'th', 'ج': 'g', 'ح': 'h', 'خ': 'kh', 'د': 'd', + 'ذ': 'th', 'ر': 'r', 'ز': 'z', 'س': 's', 'ش': 'sh', 'ص': 's', 'ض': 'd', 'ط': 't', + 'ظ': 'th', 'ع': 'aa', 'غ': 'gh', 'ف': 'f', 'ق': 'k', 'ك': 'k', 'ل': 'l', 'م': 'm', + 'ن': 'n', 'ه': 'h', 'و': 'o', 'ي': 'y' + }; + const LITHUANIAN_MAP = { + 'ą': 'a', 'č': 'c', 'ę': 'e', 'ė': 'e', 'į': 'i', 'š': 's', 'ų': 'u', + 'ū': 'u', 'ž': 'z', + 'Ą': 'A', 'Č': 'C', 'Ę': 'E', 'Ė': 'E', 'Į': 'I', 'Š': 'S', 'Ų': 'U', + 'Ū': 'U', 'Ž': 'Z' + }; + const SERBIAN_MAP = { + 'ђ': 'dj', 'ј': 'j', 'љ': 'lj', 'њ': 'nj', 'ћ': 'c', 'џ': 'dz', + 'đ': 'dj', 'Ђ': 'Dj', 'Ј': 'j', 'Љ': 'Lj', 'Њ': 'Nj', 'Ћ': 'C', + 'Џ': 'Dz', 'Đ': 'Dj' + }; + const AZERBAIJANI_MAP = { + 'ç': 'c', 'ə': 'e', 'ğ': 'g', 'ı': 'i', 'ö': 'o', 'ş': 's', 'ü': 'u', + 'Ç': 'C', 'Ə': 'E', 'Ğ': 'G', 'İ': 'I', 'Ö': 'O', 'Ş': 'S', 'Ü': 'U' + }; + const GEORGIAN_MAP = { + 'ა': 'a', 'ბ': 'b', 'გ': 'g', 'დ': 'd', 'ე': 'e', 'ვ': 'v', 'ზ': 'z', + 'თ': 't', 'ი': 'i', 'კ': 'k', 'ლ': 'l', 'მ': 'm', 'ნ': 'n', 'ო': 'o', + 'პ': 'p', 'ჟ': 'j', 'რ': 'r', 'ს': 's', 'ტ': 't', 'უ': 'u', 'ფ': 'f', + 'ქ': 'q', 'ღ': 'g', 'ყ': 'y', 'შ': 'sh', 'ჩ': 'ch', 'ც': 'c', 'ძ': 'dz', + 'წ': 'w', 'ჭ': 'ch', 'ხ': 'x', 'ჯ': 'j', 'ჰ': 'h' + }; + + const ALL_DOWNCODE_MAPS = [ + LATIN_MAP, + LATIN_SYMBOLS_MAP, + GREEK_MAP, + TURKISH_MAP, + ROMANIAN_MAP, + RUSSIAN_MAP, + UKRAINIAN_MAP, + CZECH_MAP, + SLOVAK_MAP, + POLISH_MAP, + LATVIAN_MAP, + ARABIC_MAP, + LITHUANIAN_MAP, + SERBIAN_MAP, + AZERBAIJANI_MAP, + GEORGIAN_MAP + ]; + + const Downcoder = { + 'Initialize': function() { + if (Downcoder.map) { // already made + return; + } + Downcoder.map = {}; + for (const lookup of ALL_DOWNCODE_MAPS) { + Object.assign(Downcoder.map, lookup); + } + Downcoder.regex = new RegExp(Object.keys(Downcoder.map).join('|'), 'g'); + } + }; + + function downcode(slug) { + Downcoder.Initialize(); + return slug.replace(Downcoder.regex, function(m) { + return Downcoder.map[m]; + }); + } + + + function URLify(s, num_chars, allowUnicode) { + // changes, e.g., "Petty theft" to "petty-theft" + if (!allowUnicode) { + s = downcode(s); + } + s = s.toLowerCase(); // convert to lowercase + // if downcode doesn't hit, the char will be stripped here + if (allowUnicode) { + // Keep Unicode letters including both lowercase and uppercase + // characters, whitespace, and dash; remove other characters. + s = XRegExp.replace(s, XRegExp('[^-_\\p{L}\\p{N}\\s]', 'g'), ''); + } else { + s = s.replace(/[^-\w\s]/g, ''); // remove unneeded chars + } + s = s.replace(/^\s+|\s+$/g, ''); // trim leading/trailing spaces + s = s.replace(/[-\s]+/g, '-'); // convert spaces to hyphens + s = s.substring(0, num_chars); // trim to first num_chars chars + return s.replace(/-+$/g, ''); // trim any trailing hyphens + } + window.URLify = URLify; +} diff --git a/static/admin/js/vendor/jquery/LICENSE.txt b/static/admin/js/vendor/jquery/LICENSE.txt new file mode 100644 index 0000000..f642c3f --- /dev/null +++ b/static/admin/js/vendor/jquery/LICENSE.txt @@ -0,0 +1,20 @@ +Copyright OpenJS Foundation and other contributors, https://openjsf.org/ + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/static/admin/js/vendor/jquery/jquery.js b/static/admin/js/vendor/jquery/jquery.js new file mode 100644 index 0000000..7f35c11 --- /dev/null +++ b/static/admin/js/vendor/jquery/jquery.js @@ -0,0 +1,10965 @@ +/*! + * jQuery JavaScript Library v3.6.4 + * https://jquery.com/ + * + * Includes Sizzle.js + * https://sizzlejs.com/ + * + * Copyright OpenJS Foundation and other contributors + * Released under the MIT license + * https://jquery.org/license + * + * Date: 2023-03-08T15:28Z + */ +( function( global, factory ) { + + "use strict"; + + if ( typeof module === "object" && typeof module.exports === "object" ) { + + // For CommonJS and CommonJS-like environments where a proper `window` + // is present, execute the factory and get jQuery. + // For environments that do not have a `window` with a `document` + // (such as Node.js), expose a factory as module.exports. + // This accentuates the need for the creation of a real `window`. + // e.g. var jQuery = require("jquery")(window); + // See ticket trac-14549 for more info. + module.exports = global.document ? + factory( global, true ) : + function( w ) { + if ( !w.document ) { + throw new Error( "jQuery requires a window with a document" ); + } + return factory( w ); + }; + } else { + factory( global ); + } + +// Pass this if window is not defined yet +} )( typeof window !== "undefined" ? window : this, function( window, noGlobal ) { + +// Edge <= 12 - 13+, Firefox <=18 - 45+, IE 10 - 11, Safari 5.1 - 9+, iOS 6 - 9.1 +// throw exceptions when non-strict code (e.g., ASP.NET 4.5) accesses strict mode +// arguments.callee.caller (trac-13335). But as of jQuery 3.0 (2016), strict mode should be common +// enough that all such attempts are guarded in a try block. +"use strict"; + +var arr = []; + +var getProto = Object.getPrototypeOf; + +var slice = arr.slice; + +var flat = arr.flat ? function( array ) { + return arr.flat.call( array ); +} : function( array ) { + return arr.concat.apply( [], array ); +}; + + +var push = arr.push; + +var indexOf = arr.indexOf; + +var class2type = {}; + +var toString = class2type.toString; + +var hasOwn = class2type.hasOwnProperty; + +var fnToString = hasOwn.toString; + +var ObjectFunctionString = fnToString.call( Object ); + +var support = {}; + +var isFunction = function isFunction( obj ) { + + // Support: Chrome <=57, Firefox <=52 + // In some browsers, typeof returns "function" for HTML elements + // (i.e., `typeof document.createElement( "object" ) === "function"`). + // We don't want to classify *any* DOM node as a function. + // Support: QtWeb <=3.8.5, WebKit <=534.34, wkhtmltopdf tool <=0.12.5 + // Plus for old WebKit, typeof returns "function" for HTML collections + // (e.g., `typeof document.getElementsByTagName("div") === "function"`). (gh-4756) + return typeof obj === "function" && typeof obj.nodeType !== "number" && + typeof obj.item !== "function"; + }; + + +var isWindow = function isWindow( obj ) { + return obj != null && obj === obj.window; + }; + + +var document = window.document; + + + + var preservedScriptAttributes = { + type: true, + src: true, + nonce: true, + noModule: true + }; + + function DOMEval( code, node, doc ) { + doc = doc || document; + + var i, val, + script = doc.createElement( "script" ); + + script.text = code; + if ( node ) { + for ( i in preservedScriptAttributes ) { + + // Support: Firefox 64+, Edge 18+ + // Some browsers don't support the "nonce" property on scripts. + // On the other hand, just using `getAttribute` is not enough as + // the `nonce` attribute is reset to an empty string whenever it + // becomes browsing-context connected. + // See https://github.com/whatwg/html/issues/2369 + // See https://html.spec.whatwg.org/#nonce-attributes + // The `node.getAttribute` check was added for the sake of + // `jQuery.globalEval` so that it can fake a nonce-containing node + // via an object. + val = node[ i ] || node.getAttribute && node.getAttribute( i ); + if ( val ) { + script.setAttribute( i, val ); + } + } + } + doc.head.appendChild( script ).parentNode.removeChild( script ); + } + + +function toType( obj ) { + if ( obj == null ) { + return obj + ""; + } + + // Support: Android <=2.3 only (functionish RegExp) + return typeof obj === "object" || typeof obj === "function" ? + class2type[ toString.call( obj ) ] || "object" : + typeof obj; +} +/* global Symbol */ +// Defining this global in .eslintrc.json would create a danger of using the global +// unguarded in another place, it seems safer to define global only for this module + + + +var + version = "3.6.4", + + // Define a local copy of jQuery + jQuery = function( selector, context ) { + + // The jQuery object is actually just the init constructor 'enhanced' + // Need init if jQuery is called (just allow error to be thrown if not included) + return new jQuery.fn.init( selector, context ); + }; + +jQuery.fn = jQuery.prototype = { + + // The current version of jQuery being used + jquery: version, + + constructor: jQuery, + + // The default length of a jQuery object is 0 + length: 0, + + toArray: function() { + return slice.call( this ); + }, + + // Get the Nth element in the matched element set OR + // Get the whole matched element set as a clean array + get: function( num ) { + + // Return all the elements in a clean array + if ( num == null ) { + return slice.call( this ); + } + + // Return just the one element from the set + return num < 0 ? this[ num + this.length ] : this[ num ]; + }, + + // Take an array of elements and push it onto the stack + // (returning the new matched element set) + pushStack: function( elems ) { + + // Build a new jQuery matched element set + var ret = jQuery.merge( this.constructor(), elems ); + + // Add the old object onto the stack (as a reference) + ret.prevObject = this; + + // Return the newly-formed element set + return ret; + }, + + // Execute a callback for every element in the matched set. + each: function( callback ) { + return jQuery.each( this, callback ); + }, + + map: function( callback ) { + return this.pushStack( jQuery.map( this, function( elem, i ) { + return callback.call( elem, i, elem ); + } ) ); + }, + + slice: function() { + return this.pushStack( slice.apply( this, arguments ) ); + }, + + first: function() { + return this.eq( 0 ); + }, + + last: function() { + return this.eq( -1 ); + }, + + even: function() { + return this.pushStack( jQuery.grep( this, function( _elem, i ) { + return ( i + 1 ) % 2; + } ) ); + }, + + odd: function() { + return this.pushStack( jQuery.grep( this, function( _elem, i ) { + return i % 2; + } ) ); + }, + + eq: function( i ) { + var len = this.length, + j = +i + ( i < 0 ? len : 0 ); + return this.pushStack( j >= 0 && j < len ? [ this[ j ] ] : [] ); + }, + + end: function() { + return this.prevObject || this.constructor(); + }, + + // For internal use only. + // Behaves like an Array's method, not like a jQuery method. + push: push, + sort: arr.sort, + splice: arr.splice +}; + +jQuery.extend = jQuery.fn.extend = function() { + var options, name, src, copy, copyIsArray, clone, + target = arguments[ 0 ] || {}, + i = 1, + length = arguments.length, + deep = false; + + // Handle a deep copy situation + if ( typeof target === "boolean" ) { + deep = target; + + // Skip the boolean and the target + target = arguments[ i ] || {}; + i++; + } + + // Handle case when target is a string or something (possible in deep copy) + if ( typeof target !== "object" && !isFunction( target ) ) { + target = {}; + } + + // Extend jQuery itself if only one argument is passed + if ( i === length ) { + target = this; + i--; + } + + for ( ; i < length; i++ ) { + + // Only deal with non-null/undefined values + if ( ( options = arguments[ i ] ) != null ) { + + // Extend the base object + for ( name in options ) { + copy = options[ name ]; + + // Prevent Object.prototype pollution + // Prevent never-ending loop + if ( name === "__proto__" || target === copy ) { + continue; + } + + // Recurse if we're merging plain objects or arrays + if ( deep && copy && ( jQuery.isPlainObject( copy ) || + ( copyIsArray = Array.isArray( copy ) ) ) ) { + src = target[ name ]; + + // Ensure proper type for the source value + if ( copyIsArray && !Array.isArray( src ) ) { + clone = []; + } else if ( !copyIsArray && !jQuery.isPlainObject( src ) ) { + clone = {}; + } else { + clone = src; + } + copyIsArray = false; + + // Never move original objects, clone them + target[ name ] = jQuery.extend( deep, clone, copy ); + + // Don't bring in undefined values + } else if ( copy !== undefined ) { + target[ name ] = copy; + } + } + } + } + + // Return the modified object + return target; +}; + +jQuery.extend( { + + // Unique for each copy of jQuery on the page + expando: "jQuery" + ( version + Math.random() ).replace( /\D/g, "" ), + + // Assume jQuery is ready without the ready module + isReady: true, + + error: function( msg ) { + throw new Error( msg ); + }, + + noop: function() {}, + + isPlainObject: function( obj ) { + var proto, Ctor; + + // Detect obvious negatives + // Use toString instead of jQuery.type to catch host objects + if ( !obj || toString.call( obj ) !== "[object Object]" ) { + return false; + } + + proto = getProto( obj ); + + // Objects with no prototype (e.g., `Object.create( null )`) are plain + if ( !proto ) { + return true; + } + + // Objects with prototype are plain iff they were constructed by a global Object function + Ctor = hasOwn.call( proto, "constructor" ) && proto.constructor; + return typeof Ctor === "function" && fnToString.call( Ctor ) === ObjectFunctionString; + }, + + isEmptyObject: function( obj ) { + var name; + + for ( name in obj ) { + return false; + } + return true; + }, + + // Evaluates a script in a provided context; falls back to the global one + // if not specified. + globalEval: function( code, options, doc ) { + DOMEval( code, { nonce: options && options.nonce }, doc ); + }, + + each: function( obj, callback ) { + var length, i = 0; + + if ( isArrayLike( obj ) ) { + length = obj.length; + for ( ; i < length; i++ ) { + if ( callback.call( obj[ i ], i, obj[ i ] ) === false ) { + break; + } + } + } else { + for ( i in obj ) { + if ( callback.call( obj[ i ], i, obj[ i ] ) === false ) { + break; + } + } + } + + return obj; + }, + + // results is for internal usage only + makeArray: function( arr, results ) { + var ret = results || []; + + if ( arr != null ) { + if ( isArrayLike( Object( arr ) ) ) { + jQuery.merge( ret, + typeof arr === "string" ? + [ arr ] : arr + ); + } else { + push.call( ret, arr ); + } + } + + return ret; + }, + + inArray: function( elem, arr, i ) { + return arr == null ? -1 : indexOf.call( arr, elem, i ); + }, + + // Support: Android <=4.0 only, PhantomJS 1 only + // push.apply(_, arraylike) throws on ancient WebKit + merge: function( first, second ) { + var len = +second.length, + j = 0, + i = first.length; + + for ( ; j < len; j++ ) { + first[ i++ ] = second[ j ]; + } + + first.length = i; + + return first; + }, + + grep: function( elems, callback, invert ) { + var callbackInverse, + matches = [], + i = 0, + length = elems.length, + callbackExpect = !invert; + + // Go through the array, only saving the items + // that pass the validator function + for ( ; i < length; i++ ) { + callbackInverse = !callback( elems[ i ], i ); + if ( callbackInverse !== callbackExpect ) { + matches.push( elems[ i ] ); + } + } + + return matches; + }, + + // arg is for internal usage only + map: function( elems, callback, arg ) { + var length, value, + i = 0, + ret = []; + + // Go through the array, translating each of the items to their new values + if ( isArrayLike( elems ) ) { + length = elems.length; + for ( ; i < length; i++ ) { + value = callback( elems[ i ], i, arg ); + + if ( value != null ) { + ret.push( value ); + } + } + + // Go through every key on the object, + } else { + for ( i in elems ) { + value = callback( elems[ i ], i, arg ); + + if ( value != null ) { + ret.push( value ); + } + } + } + + // Flatten any nested arrays + return flat( ret ); + }, + + // A global GUID counter for objects + guid: 1, + + // jQuery.support is not used in Core but other projects attach their + // properties to it so it needs to exist. + support: support +} ); + +if ( typeof Symbol === "function" ) { + jQuery.fn[ Symbol.iterator ] = arr[ Symbol.iterator ]; +} + +// Populate the class2type map +jQuery.each( "Boolean Number String Function Array Date RegExp Object Error Symbol".split( " " ), + function( _i, name ) { + class2type[ "[object " + name + "]" ] = name.toLowerCase(); + } ); + +function isArrayLike( obj ) { + + // Support: real iOS 8.2 only (not reproducible in simulator) + // `in` check used to prevent JIT error (gh-2145) + // hasOwn isn't used here due to false negatives + // regarding Nodelist length in IE + var length = !!obj && "length" in obj && obj.length, + type = toType( obj ); + + if ( isFunction( obj ) || isWindow( obj ) ) { + return false; + } + + return type === "array" || length === 0 || + typeof length === "number" && length > 0 && ( length - 1 ) in obj; +} +var Sizzle = +/*! + * Sizzle CSS Selector Engine v2.3.10 + * https://sizzlejs.com/ + * + * Copyright JS Foundation and other contributors + * Released under the MIT license + * https://js.foundation/ + * + * Date: 2023-02-14 + */ +( function( window ) { +var i, + support, + Expr, + getText, + isXML, + tokenize, + compile, + select, + outermostContext, + sortInput, + hasDuplicate, + + // Local document vars + setDocument, + document, + docElem, + documentIsHTML, + rbuggyQSA, + rbuggyMatches, + matches, + contains, + + // Instance-specific data + expando = "sizzle" + 1 * new Date(), + preferredDoc = window.document, + dirruns = 0, + done = 0, + classCache = createCache(), + tokenCache = createCache(), + compilerCache = createCache(), + nonnativeSelectorCache = createCache(), + sortOrder = function( a, b ) { + if ( a === b ) { + hasDuplicate = true; + } + return 0; + }, + + // Instance methods + hasOwn = ( {} ).hasOwnProperty, + arr = [], + pop = arr.pop, + pushNative = arr.push, + push = arr.push, + slice = arr.slice, + + // Use a stripped-down indexOf as it's faster than native + // https://jsperf.com/thor-indexof-vs-for/5 + indexOf = function( list, elem ) { + var i = 0, + len = list.length; + for ( ; i < len; i++ ) { + if ( list[ i ] === elem ) { + return i; + } + } + return -1; + }, + + booleans = "checked|selected|async|autofocus|autoplay|controls|defer|disabled|hidden|" + + "ismap|loop|multiple|open|readonly|required|scoped", + + // Regular expressions + + // http://www.w3.org/TR/css3-selectors/#whitespace + whitespace = "[\\x20\\t\\r\\n\\f]", + + // https://www.w3.org/TR/css-syntax-3/#ident-token-diagram + identifier = "(?:\\\\[\\da-fA-F]{1,6}" + whitespace + + "?|\\\\[^\\r\\n\\f]|[\\w-]|[^\0-\\x7f])+", + + // Attribute selectors: http://www.w3.org/TR/selectors/#attribute-selectors + attributes = "\\[" + whitespace + "*(" + identifier + ")(?:" + whitespace + + + // Operator (capture 2) + "*([*^$|!~]?=)" + whitespace + + + // "Attribute values must be CSS identifiers [capture 5] + // or strings [capture 3 or capture 4]" + "*(?:'((?:\\\\.|[^\\\\'])*)'|\"((?:\\\\.|[^\\\\\"])*)\"|(" + identifier + "))|)" + + whitespace + "*\\]", + + pseudos = ":(" + identifier + ")(?:\\((" + + + // To reduce the number of selectors needing tokenize in the preFilter, prefer arguments: + // 1. quoted (capture 3; capture 4 or capture 5) + "('((?:\\\\.|[^\\\\'])*)'|\"((?:\\\\.|[^\\\\\"])*)\")|" + + + // 2. simple (capture 6) + "((?:\\\\.|[^\\\\()[\\]]|" + attributes + ")*)|" + + + // 3. anything else (capture 2) + ".*" + + ")\\)|)", + + // Leading and non-escaped trailing whitespace, capturing some non-whitespace characters preceding the latter + rwhitespace = new RegExp( whitespace + "+", "g" ), + rtrim = new RegExp( "^" + whitespace + "+|((?:^|[^\\\\])(?:\\\\.)*)" + + whitespace + "+$", "g" ), + + rcomma = new RegExp( "^" + whitespace + "*," + whitespace + "*" ), + rleadingCombinator = new RegExp( "^" + whitespace + "*([>+~]|" + whitespace + ")" + whitespace + + "*" ), + rdescend = new RegExp( whitespace + "|>" ), + + rpseudo = new RegExp( pseudos ), + ridentifier = new RegExp( "^" + identifier + "$" ), + + matchExpr = { + "ID": new RegExp( "^#(" + identifier + ")" ), + "CLASS": new RegExp( "^\\.(" + identifier + ")" ), + "TAG": new RegExp( "^(" + identifier + "|[*])" ), + "ATTR": new RegExp( "^" + attributes ), + "PSEUDO": new RegExp( "^" + pseudos ), + "CHILD": new RegExp( "^:(only|first|last|nth|nth-last)-(child|of-type)(?:\\(" + + whitespace + "*(even|odd|(([+-]|)(\\d*)n|)" + whitespace + "*(?:([+-]|)" + + whitespace + "*(\\d+)|))" + whitespace + "*\\)|)", "i" ), + "bool": new RegExp( "^(?:" + booleans + ")$", "i" ), + + // For use in libraries implementing .is() + // We use this for POS matching in `select` + "needsContext": new RegExp( "^" + whitespace + + "*[>+~]|:(even|odd|eq|gt|lt|nth|first|last)(?:\\(" + whitespace + + "*((?:-\\d)?\\d*)" + whitespace + "*\\)|)(?=[^-]|$)", "i" ) + }, + + rhtml = /HTML$/i, + rinputs = /^(?:input|select|textarea|button)$/i, + rheader = /^h\d$/i, + + rnative = /^[^{]+\{\s*\[native \w/, + + // Easily-parseable/retrievable ID or TAG or CLASS selectors + rquickExpr = /^(?:#([\w-]+)|(\w+)|\.([\w-]+))$/, + + rsibling = /[+~]/, + + // CSS escapes + // http://www.w3.org/TR/CSS21/syndata.html#escaped-characters + runescape = new RegExp( "\\\\[\\da-fA-F]{1,6}" + whitespace + "?|\\\\([^\\r\\n\\f])", "g" ), + funescape = function( escape, nonHex ) { + var high = "0x" + escape.slice( 1 ) - 0x10000; + + return nonHex ? + + // Strip the backslash prefix from a non-hex escape sequence + nonHex : + + // Replace a hexadecimal escape sequence with the encoded Unicode code point + // Support: IE <=11+ + // For values outside the Basic Multilingual Plane (BMP), manually construct a + // surrogate pair + high < 0 ? + String.fromCharCode( high + 0x10000 ) : + String.fromCharCode( high >> 10 | 0xD800, high & 0x3FF | 0xDC00 ); + }, + + // CSS string/identifier serialization + // https://drafts.csswg.org/cssom/#common-serializing-idioms + rcssescape = /([\0-\x1f\x7f]|^-?\d)|^-$|[^\0-\x1f\x7f-\uFFFF\w-]/g, + fcssescape = function( ch, asCodePoint ) { + if ( asCodePoint ) { + + // U+0000 NULL becomes U+FFFD REPLACEMENT CHARACTER + if ( ch === "\0" ) { + return "\uFFFD"; + } + + // Control characters and (dependent upon position) numbers get escaped as code points + return ch.slice( 0, -1 ) + "\\" + + ch.charCodeAt( ch.length - 1 ).toString( 16 ) + " "; + } + + // Other potentially-special ASCII characters get backslash-escaped + return "\\" + ch; + }, + + // Used for iframes + // See setDocument() + // Removing the function wrapper causes a "Permission Denied" + // error in IE + unloadHandler = function() { + setDocument(); + }, + + inDisabledFieldset = addCombinator( + function( elem ) { + return elem.disabled === true && elem.nodeName.toLowerCase() === "fieldset"; + }, + { dir: "parentNode", next: "legend" } + ); + +// Optimize for push.apply( _, NodeList ) +try { + push.apply( + ( arr = slice.call( preferredDoc.childNodes ) ), + preferredDoc.childNodes + ); + + // Support: Android<4.0 + // Detect silently failing push.apply + // eslint-disable-next-line no-unused-expressions + arr[ preferredDoc.childNodes.length ].nodeType; +} catch ( e ) { + push = { apply: arr.length ? + + // Leverage slice if possible + function( target, els ) { + pushNative.apply( target, slice.call( els ) ); + } : + + // Support: IE<9 + // Otherwise append directly + function( target, els ) { + var j = target.length, + i = 0; + + // Can't trust NodeList.length + while ( ( target[ j++ ] = els[ i++ ] ) ) {} + target.length = j - 1; + } + }; +} + +function Sizzle( selector, context, results, seed ) { + var m, i, elem, nid, match, groups, newSelector, + newContext = context && context.ownerDocument, + + // nodeType defaults to 9, since context defaults to document + nodeType = context ? context.nodeType : 9; + + results = results || []; + + // Return early from calls with invalid selector or context + if ( typeof selector !== "string" || !selector || + nodeType !== 1 && nodeType !== 9 && nodeType !== 11 ) { + + return results; + } + + // Try to shortcut find operations (as opposed to filters) in HTML documents + if ( !seed ) { + setDocument( context ); + context = context || document; + + if ( documentIsHTML ) { + + // If the selector is sufficiently simple, try using a "get*By*" DOM method + // (excepting DocumentFragment context, where the methods don't exist) + if ( nodeType !== 11 && ( match = rquickExpr.exec( selector ) ) ) { + + // ID selector + if ( ( m = match[ 1 ] ) ) { + + // Document context + if ( nodeType === 9 ) { + if ( ( elem = context.getElementById( m ) ) ) { + + // Support: IE, Opera, Webkit + // TODO: identify versions + // getElementById can match elements by name instead of ID + if ( elem.id === m ) { + results.push( elem ); + return results; + } + } else { + return results; + } + + // Element context + } else { + + // Support: IE, Opera, Webkit + // TODO: identify versions + // getElementById can match elements by name instead of ID + if ( newContext && ( elem = newContext.getElementById( m ) ) && + contains( context, elem ) && + elem.id === m ) { + + results.push( elem ); + return results; + } + } + + // Type selector + } else if ( match[ 2 ] ) { + push.apply( results, context.getElementsByTagName( selector ) ); + return results; + + // Class selector + } else if ( ( m = match[ 3 ] ) && support.getElementsByClassName && + context.getElementsByClassName ) { + + push.apply( results, context.getElementsByClassName( m ) ); + return results; + } + } + + // Take advantage of querySelectorAll + if ( support.qsa && + !nonnativeSelectorCache[ selector + " " ] && + ( !rbuggyQSA || !rbuggyQSA.test( selector ) ) && + + // Support: IE 8 only + // Exclude object elements + ( nodeType !== 1 || context.nodeName.toLowerCase() !== "object" ) ) { + + newSelector = selector; + newContext = context; + + // qSA considers elements outside a scoping root when evaluating child or + // descendant combinators, which is not what we want. + // In such cases, we work around the behavior by prefixing every selector in the + // list with an ID selector referencing the scope context. + // The technique has to be used as well when a leading combinator is used + // as such selectors are not recognized by querySelectorAll. + // Thanks to Andrew Dupont for this technique. + if ( nodeType === 1 && + ( rdescend.test( selector ) || rleadingCombinator.test( selector ) ) ) { + + // Expand context for sibling selectors + newContext = rsibling.test( selector ) && testContext( context.parentNode ) || + context; + + // We can use :scope instead of the ID hack if the browser + // supports it & if we're not changing the context. + if ( newContext !== context || !support.scope ) { + + // Capture the context ID, setting it first if necessary + if ( ( nid = context.getAttribute( "id" ) ) ) { + nid = nid.replace( rcssescape, fcssescape ); + } else { + context.setAttribute( "id", ( nid = expando ) ); + } + } + + // Prefix every selector in the list + groups = tokenize( selector ); + i = groups.length; + while ( i-- ) { + groups[ i ] = ( nid ? "#" + nid : ":scope" ) + " " + + toSelector( groups[ i ] ); + } + newSelector = groups.join( "," ); + } + + try { + push.apply( results, + newContext.querySelectorAll( newSelector ) + ); + return results; + } catch ( qsaError ) { + nonnativeSelectorCache( selector, true ); + } finally { + if ( nid === expando ) { + context.removeAttribute( "id" ); + } + } + } + } + } + + // All others + return select( selector.replace( rtrim, "$1" ), context, results, seed ); +} + +/** + * Create key-value caches of limited size + * @returns {function(string, object)} Returns the Object data after storing it on itself with + * property name the (space-suffixed) string and (if the cache is larger than Expr.cacheLength) + * deleting the oldest entry + */ +function createCache() { + var keys = []; + + function cache( key, value ) { + + // Use (key + " ") to avoid collision with native prototype properties (see Issue #157) + if ( keys.push( key + " " ) > Expr.cacheLength ) { + + // Only keep the most recent entries + delete cache[ keys.shift() ]; + } + return ( cache[ key + " " ] = value ); + } + return cache; +} + +/** + * Mark a function for special use by Sizzle + * @param {Function} fn The function to mark + */ +function markFunction( fn ) { + fn[ expando ] = true; + return fn; +} + +/** + * Support testing using an element + * @param {Function} fn Passed the created element and returns a boolean result + */ +function assert( fn ) { + var el = document.createElement( "fieldset" ); + + try { + return !!fn( el ); + } catch ( e ) { + return false; + } finally { + + // Remove from its parent by default + if ( el.parentNode ) { + el.parentNode.removeChild( el ); + } + + // release memory in IE + el = null; + } +} + +/** + * Adds the same handler for all of the specified attrs + * @param {String} attrs Pipe-separated list of attributes + * @param {Function} handler The method that will be applied + */ +function addHandle( attrs, handler ) { + var arr = attrs.split( "|" ), + i = arr.length; + + while ( i-- ) { + Expr.attrHandle[ arr[ i ] ] = handler; + } +} + +/** + * Checks document order of two siblings + * @param {Element} a + * @param {Element} b + * @returns {Number} Returns less than 0 if a precedes b, greater than 0 if a follows b + */ +function siblingCheck( a, b ) { + var cur = b && a, + diff = cur && a.nodeType === 1 && b.nodeType === 1 && + a.sourceIndex - b.sourceIndex; + + // Use IE sourceIndex if available on both nodes + if ( diff ) { + return diff; + } + + // Check if b follows a + if ( cur ) { + while ( ( cur = cur.nextSibling ) ) { + if ( cur === b ) { + return -1; + } + } + } + + return a ? 1 : -1; +} + +/** + * Returns a function to use in pseudos for input types + * @param {String} type + */ +function createInputPseudo( type ) { + return function( elem ) { + var name = elem.nodeName.toLowerCase(); + return name === "input" && elem.type === type; + }; +} + +/** + * Returns a function to use in pseudos for buttons + * @param {String} type + */ +function createButtonPseudo( type ) { + return function( elem ) { + var name = elem.nodeName.toLowerCase(); + return ( name === "input" || name === "button" ) && elem.type === type; + }; +} + +/** + * Returns a function to use in pseudos for :enabled/:disabled + * @param {Boolean} disabled true for :disabled; false for :enabled + */ +function createDisabledPseudo( disabled ) { + + // Known :disabled false positives: fieldset[disabled] > legend:nth-of-type(n+2) :can-disable + return function( elem ) { + + // Only certain elements can match :enabled or :disabled + // https://html.spec.whatwg.org/multipage/scripting.html#selector-enabled + // https://html.spec.whatwg.org/multipage/scripting.html#selector-disabled + if ( "form" in elem ) { + + // Check for inherited disabledness on relevant non-disabled elements: + // * listed form-associated elements in a disabled fieldset + // https://html.spec.whatwg.org/multipage/forms.html#category-listed + // https://html.spec.whatwg.org/multipage/forms.html#concept-fe-disabled + // * option elements in a disabled optgroup + // https://html.spec.whatwg.org/multipage/forms.html#concept-option-disabled + // All such elements have a "form" property. + if ( elem.parentNode && elem.disabled === false ) { + + // Option elements defer to a parent optgroup if present + if ( "label" in elem ) { + if ( "label" in elem.parentNode ) { + return elem.parentNode.disabled === disabled; + } else { + return elem.disabled === disabled; + } + } + + // Support: IE 6 - 11 + // Use the isDisabled shortcut property to check for disabled fieldset ancestors + return elem.isDisabled === disabled || + + // Where there is no isDisabled, check manually + /* jshint -W018 */ + elem.isDisabled !== !disabled && + inDisabledFieldset( elem ) === disabled; + } + + return elem.disabled === disabled; + + // Try to winnow out elements that can't be disabled before trusting the disabled property. + // Some victims get caught in our net (label, legend, menu, track), but it shouldn't + // even exist on them, let alone have a boolean value. + } else if ( "label" in elem ) { + return elem.disabled === disabled; + } + + // Remaining elements are neither :enabled nor :disabled + return false; + }; +} + +/** + * Returns a function to use in pseudos for positionals + * @param {Function} fn + */ +function createPositionalPseudo( fn ) { + return markFunction( function( argument ) { + argument = +argument; + return markFunction( function( seed, matches ) { + var j, + matchIndexes = fn( [], seed.length, argument ), + i = matchIndexes.length; + + // Match elements found at the specified indexes + while ( i-- ) { + if ( seed[ ( j = matchIndexes[ i ] ) ] ) { + seed[ j ] = !( matches[ j ] = seed[ j ] ); + } + } + } ); + } ); +} + +/** + * Checks a node for validity as a Sizzle context + * @param {Element|Object=} context + * @returns {Element|Object|Boolean} The input node if acceptable, otherwise a falsy value + */ +function testContext( context ) { + return context && typeof context.getElementsByTagName !== "undefined" && context; +} + +// Expose support vars for convenience +support = Sizzle.support = {}; + +/** + * Detects XML nodes + * @param {Element|Object} elem An element or a document + * @returns {Boolean} True iff elem is a non-HTML XML node + */ +isXML = Sizzle.isXML = function( elem ) { + var namespace = elem && elem.namespaceURI, + docElem = elem && ( elem.ownerDocument || elem ).documentElement; + + // Support: IE <=8 + // Assume HTML when documentElement doesn't yet exist, such as inside loading iframes + // https://bugs.jquery.com/ticket/4833 + return !rhtml.test( namespace || docElem && docElem.nodeName || "HTML" ); +}; + +/** + * Sets document-related variables once based on the current document + * @param {Element|Object} [doc] An element or document object to use to set the document + * @returns {Object} Returns the current document + */ +setDocument = Sizzle.setDocument = function( node ) { + var hasCompare, subWindow, + doc = node ? node.ownerDocument || node : preferredDoc; + + // Return early if doc is invalid or already selected + // Support: IE 11+, Edge 17 - 18+ + // IE/Edge sometimes throw a "Permission denied" error when strict-comparing + // two documents; shallow comparisons work. + // eslint-disable-next-line eqeqeq + if ( doc == document || doc.nodeType !== 9 || !doc.documentElement ) { + return document; + } + + // Update global variables + document = doc; + docElem = document.documentElement; + documentIsHTML = !isXML( document ); + + // Support: IE 9 - 11+, Edge 12 - 18+ + // Accessing iframe documents after unload throws "permission denied" errors (jQuery #13936) + // Support: IE 11+, Edge 17 - 18+ + // IE/Edge sometimes throw a "Permission denied" error when strict-comparing + // two documents; shallow comparisons work. + // eslint-disable-next-line eqeqeq + if ( preferredDoc != document && + ( subWindow = document.defaultView ) && subWindow.top !== subWindow ) { + + // Support: IE 11, Edge + if ( subWindow.addEventListener ) { + subWindow.addEventListener( "unload", unloadHandler, false ); + + // Support: IE 9 - 10 only + } else if ( subWindow.attachEvent ) { + subWindow.attachEvent( "onunload", unloadHandler ); + } + } + + // Support: IE 8 - 11+, Edge 12 - 18+, Chrome <=16 - 25 only, Firefox <=3.6 - 31 only, + // Safari 4 - 5 only, Opera <=11.6 - 12.x only + // IE/Edge & older browsers don't support the :scope pseudo-class. + // Support: Safari 6.0 only + // Safari 6.0 supports :scope but it's an alias of :root there. + support.scope = assert( function( el ) { + docElem.appendChild( el ).appendChild( document.createElement( "div" ) ); + return typeof el.querySelectorAll !== "undefined" && + !el.querySelectorAll( ":scope fieldset div" ).length; + } ); + + // Support: Chrome 105 - 110+, Safari 15.4 - 16.3+ + // Make sure the the `:has()` argument is parsed unforgivingly. + // We include `*` in the test to detect buggy implementations that are + // _selectively_ forgiving (specifically when the list includes at least + // one valid selector). + // Note that we treat complete lack of support for `:has()` as if it were + // spec-compliant support, which is fine because use of `:has()` in such + // environments will fail in the qSA path and fall back to jQuery traversal + // anyway. + support.cssHas = assert( function() { + try { + document.querySelector( ":has(*,:jqfake)" ); + return false; + } catch ( e ) { + return true; + } + } ); + + /* Attributes + ---------------------------------------------------------------------- */ + + // Support: IE<8 + // Verify that getAttribute really returns attributes and not properties + // (excepting IE8 booleans) + support.attributes = assert( function( el ) { + el.className = "i"; + return !el.getAttribute( "className" ); + } ); + + /* getElement(s)By* + ---------------------------------------------------------------------- */ + + // Check if getElementsByTagName("*") returns only elements + support.getElementsByTagName = assert( function( el ) { + el.appendChild( document.createComment( "" ) ); + return !el.getElementsByTagName( "*" ).length; + } ); + + // Support: IE<9 + support.getElementsByClassName = rnative.test( document.getElementsByClassName ); + + // Support: IE<10 + // Check if getElementById returns elements by name + // The broken getElementById methods don't pick up programmatically-set names, + // so use a roundabout getElementsByName test + support.getById = assert( function( el ) { + docElem.appendChild( el ).id = expando; + return !document.getElementsByName || !document.getElementsByName( expando ).length; + } ); + + // ID filter and find + if ( support.getById ) { + Expr.filter[ "ID" ] = function( id ) { + var attrId = id.replace( runescape, funescape ); + return function( elem ) { + return elem.getAttribute( "id" ) === attrId; + }; + }; + Expr.find[ "ID" ] = function( id, context ) { + if ( typeof context.getElementById !== "undefined" && documentIsHTML ) { + var elem = context.getElementById( id ); + return elem ? [ elem ] : []; + } + }; + } else { + Expr.filter[ "ID" ] = function( id ) { + var attrId = id.replace( runescape, funescape ); + return function( elem ) { + var node = typeof elem.getAttributeNode !== "undefined" && + elem.getAttributeNode( "id" ); + return node && node.value === attrId; + }; + }; + + // Support: IE 6 - 7 only + // getElementById is not reliable as a find shortcut + Expr.find[ "ID" ] = function( id, context ) { + if ( typeof context.getElementById !== "undefined" && documentIsHTML ) { + var node, i, elems, + elem = context.getElementById( id ); + + if ( elem ) { + + // Verify the id attribute + node = elem.getAttributeNode( "id" ); + if ( node && node.value === id ) { + return [ elem ]; + } + + // Fall back on getElementsByName + elems = context.getElementsByName( id ); + i = 0; + while ( ( elem = elems[ i++ ] ) ) { + node = elem.getAttributeNode( "id" ); + if ( node && node.value === id ) { + return [ elem ]; + } + } + } + + return []; + } + }; + } + + // Tag + Expr.find[ "TAG" ] = support.getElementsByTagName ? + function( tag, context ) { + if ( typeof context.getElementsByTagName !== "undefined" ) { + return context.getElementsByTagName( tag ); + + // DocumentFragment nodes don't have gEBTN + } else if ( support.qsa ) { + return context.querySelectorAll( tag ); + } + } : + + function( tag, context ) { + var elem, + tmp = [], + i = 0, + + // By happy coincidence, a (broken) gEBTN appears on DocumentFragment nodes too + results = context.getElementsByTagName( tag ); + + // Filter out possible comments + if ( tag === "*" ) { + while ( ( elem = results[ i++ ] ) ) { + if ( elem.nodeType === 1 ) { + tmp.push( elem ); + } + } + + return tmp; + } + return results; + }; + + // Class + Expr.find[ "CLASS" ] = support.getElementsByClassName && function( className, context ) { + if ( typeof context.getElementsByClassName !== "undefined" && documentIsHTML ) { + return context.getElementsByClassName( className ); + } + }; + + /* QSA/matchesSelector + ---------------------------------------------------------------------- */ + + // QSA and matchesSelector support + + // matchesSelector(:active) reports false when true (IE9/Opera 11.5) + rbuggyMatches = []; + + // qSa(:focus) reports false when true (Chrome 21) + // We allow this because of a bug in IE8/9 that throws an error + // whenever `document.activeElement` is accessed on an iframe + // So, we allow :focus to pass through QSA all the time to avoid the IE error + // See https://bugs.jquery.com/ticket/13378 + rbuggyQSA = []; + + if ( ( support.qsa = rnative.test( document.querySelectorAll ) ) ) { + + // Build QSA regex + // Regex strategy adopted from Diego Perini + assert( function( el ) { + + var input; + + // Select is set to empty string on purpose + // This is to test IE's treatment of not explicitly + // setting a boolean content attribute, + // since its presence should be enough + // https://bugs.jquery.com/ticket/12359 + docElem.appendChild( el ).innerHTML = "" + + ""; + + // Support: IE8, Opera 11-12.16 + // Nothing should be selected when empty strings follow ^= or $= or *= + // The test attribute must be unknown in Opera but "safe" for WinRT + // https://msdn.microsoft.com/en-us/library/ie/hh465388.aspx#attribute_section + if ( el.querySelectorAll( "[msallowcapture^='']" ).length ) { + rbuggyQSA.push( "[*^$]=" + whitespace + "*(?:''|\"\")" ); + } + + // Support: IE8 + // Boolean attributes and "value" are not treated correctly + if ( !el.querySelectorAll( "[selected]" ).length ) { + rbuggyQSA.push( "\\[" + whitespace + "*(?:value|" + booleans + ")" ); + } + + // Support: Chrome<29, Android<4.4, Safari<7.0+, iOS<7.0+, PhantomJS<1.9.8+ + if ( !el.querySelectorAll( "[id~=" + expando + "-]" ).length ) { + rbuggyQSA.push( "~=" ); + } + + // Support: IE 11+, Edge 15 - 18+ + // IE 11/Edge don't find elements on a `[name='']` query in some cases. + // Adding a temporary attribute to the document before the selection works + // around the issue. + // Interestingly, IE 10 & older don't seem to have the issue. + input = document.createElement( "input" ); + input.setAttribute( "name", "" ); + el.appendChild( input ); + if ( !el.querySelectorAll( "[name='']" ).length ) { + rbuggyQSA.push( "\\[" + whitespace + "*name" + whitespace + "*=" + + whitespace + "*(?:''|\"\")" ); + } + + // Webkit/Opera - :checked should return selected option elements + // http://www.w3.org/TR/2011/REC-css3-selectors-20110929/#checked + // IE8 throws error here and will not see later tests + if ( !el.querySelectorAll( ":checked" ).length ) { + rbuggyQSA.push( ":checked" ); + } + + // Support: Safari 8+, iOS 8+ + // https://bugs.webkit.org/show_bug.cgi?id=136851 + // In-page `selector#id sibling-combinator selector` fails + if ( !el.querySelectorAll( "a#" + expando + "+*" ).length ) { + rbuggyQSA.push( ".#.+[+~]" ); + } + + // Support: Firefox <=3.6 - 5 only + // Old Firefox doesn't throw on a badly-escaped identifier. + el.querySelectorAll( "\\\f" ); + rbuggyQSA.push( "[\\r\\n\\f]" ); + } ); + + assert( function( el ) { + el.innerHTML = "" + + ""; + + // Support: Windows 8 Native Apps + // The type and name attributes are restricted during .innerHTML assignment + var input = document.createElement( "input" ); + input.setAttribute( "type", "hidden" ); + el.appendChild( input ).setAttribute( "name", "D" ); + + // Support: IE8 + // Enforce case-sensitivity of name attribute + if ( el.querySelectorAll( "[name=d]" ).length ) { + rbuggyQSA.push( "name" + whitespace + "*[*^$|!~]?=" ); + } + + // FF 3.5 - :enabled/:disabled and hidden elements (hidden elements are still enabled) + // IE8 throws error here and will not see later tests + if ( el.querySelectorAll( ":enabled" ).length !== 2 ) { + rbuggyQSA.push( ":enabled", ":disabled" ); + } + + // Support: IE9-11+ + // IE's :disabled selector does not pick up the children of disabled fieldsets + docElem.appendChild( el ).disabled = true; + if ( el.querySelectorAll( ":disabled" ).length !== 2 ) { + rbuggyQSA.push( ":enabled", ":disabled" ); + } + + // Support: Opera 10 - 11 only + // Opera 10-11 does not throw on post-comma invalid pseudos + el.querySelectorAll( "*,:x" ); + rbuggyQSA.push( ",.*:" ); + } ); + } + + if ( ( support.matchesSelector = rnative.test( ( matches = docElem.matches || + docElem.webkitMatchesSelector || + docElem.mozMatchesSelector || + docElem.oMatchesSelector || + docElem.msMatchesSelector ) ) ) ) { + + assert( function( el ) { + + // Check to see if it's possible to do matchesSelector + // on a disconnected node (IE 9) + support.disconnectedMatch = matches.call( el, "*" ); + + // This should fail with an exception + // Gecko does not error, returns false instead + matches.call( el, "[s!='']:x" ); + rbuggyMatches.push( "!=", pseudos ); + } ); + } + + if ( !support.cssHas ) { + + // Support: Chrome 105 - 110+, Safari 15.4 - 16.3+ + // Our regular `try-catch` mechanism fails to detect natively-unsupported + // pseudo-classes inside `:has()` (such as `:has(:contains("Foo"))`) + // in browsers that parse the `:has()` argument as a forgiving selector list. + // https://drafts.csswg.org/selectors/#relational now requires the argument + // to be parsed unforgivingly, but browsers have not yet fully adjusted. + rbuggyQSA.push( ":has" ); + } + + rbuggyQSA = rbuggyQSA.length && new RegExp( rbuggyQSA.join( "|" ) ); + rbuggyMatches = rbuggyMatches.length && new RegExp( rbuggyMatches.join( "|" ) ); + + /* Contains + ---------------------------------------------------------------------- */ + hasCompare = rnative.test( docElem.compareDocumentPosition ); + + // Element contains another + // Purposefully self-exclusive + // As in, an element does not contain itself + contains = hasCompare || rnative.test( docElem.contains ) ? + function( a, b ) { + + // Support: IE <9 only + // IE doesn't have `contains` on `document` so we need to check for + // `documentElement` presence. + // We need to fall back to `a` when `documentElement` is missing + // as `ownerDocument` of elements within `