diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..d4f0f57 --- /dev/null +++ b/.env.example @@ -0,0 +1,2 @@ +CHATWOOT_TOKEN=example +CHATWOOT_IDENTITY_VALIDATION=example \ No newline at end of file diff --git a/.github/workflows/deploy-dev.yml b/.github/workflows/deploy-dev.yml new file mode 100644 index 0000000..22bbf3d --- /dev/null +++ b/.github/workflows/deploy-dev.yml @@ -0,0 +1,55 @@ +name: Deploy to Dev + +on: + push: + branches: + - dev + +jobs: + deploy: + runs-on: ubuntu-latest + environment: DEV + + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: '3.9' + + + - name: Configure AWS Credentials + uses: aws-actions/configure-aws-credentials@v2 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: 'us-east-1' + + - name: Download env.dev from S3 + run: | + aws s3 cp s3://faprivate/envs/env.dev env.dev + + - name: Create Virtual Environment and Install dependencies + run: | + python -m venv venv + source venv/bin/activate + python -m pip install --upgrade pip + pip install -r requirements.txt + + - name: Update Zappa environment variables and deploy + run: | + source venv/bin/activate + python update_zappa_envs.py dev + zappa update dev + + - name: Run Migrations + run: | + source venv/bin/activate + zappa manage dev migrate + + - name: Run Collectstatic + run: | + source venv/bin/activate + zappa manage dev "collectstatic --noinput" \ No newline at end of file diff --git a/.github/workflows/deploy-prod.yml b/.github/workflows/deploy-prod.yml new file mode 100644 index 0000000..669bc24 --- /dev/null +++ b/.github/workflows/deploy-prod.yml @@ -0,0 +1,55 @@ +name: Deploy to Prod + +on: + push: + branches: + - main + +jobs: + deploy: + runs-on: ubuntu-latest + environment: PROD + + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: '3.9' + + + - name: Configure AWS Credentials + uses: aws-actions/configure-aws-credentials@v2 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: 'us-east-1' + + - name: Download env.prod from S3 + run: | + aws s3 cp s3://faprivate/envs/env.prod env.prod + + - name: Create Virtual Environment and Install dependencies + run: | + python -m venv venv + source venv/bin/activate + python -m pip install --upgrade pip + pip install -r requirements.txt + + - name: Update Zappa environment variables and deploy + run: | + source venv/bin/activate + python update_zappa_envs.py prod + zappa update prod + + - name: Run Migrations + run: | + source venv/bin/activate + zappa manage prod migrate + + - name: Run Collectstatic + run: | + source venv/bin/activate + zappa manage prod "collectstatic --noinput" \ No newline at end of file diff --git a/.gitignore b/.gitignore index 95118c3..e3a6e4f 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ deprepagos/media *DS_Store dbs/ venv/* +.env \ No newline at end of file diff --git a/README.md b/README.md index 25748b7..16947a7 100644 --- a/README.md +++ b/README.md @@ -11,9 +11,50 @@ source venv/bin/activate pip install -r requirements.txt pip install -r requirements-dev.txt cp deprepagos/local_settings.py.example deprepagos/local_settings.py -whoami # copy the output -open deprepagos/local_settings.py # and paste the output as `DB_USER` value +cp .env.example .env createdb deprepagos_development +``` + +## Set envs. + +You can copy the values from dev environment. + +### DB + +Use your recently created local DB + +``` +DB_DATABASE +DB_HOST +DB_PORT +DB_USER +``` + +### MercadoPago + +Create a SELLER TEST USER in MercadoPago and set the following +envs. [Instructions here]('https://www.mercadopago.com.ar/developers/es/docs/your-integrations/test/accounts') + +Set the envs `MERCADOPAGO_PUBLIC_KEY`, `MERCADOPAGO_ACCESS_TOKEN` with the onces from the SELLER TEST USER. + +Then set up a [webhook]('https://www.mercadopago.com.ar/developers/es/docs/your-integrations/notifications/webhooks') +pointing to `{your local env url}/webhooks/mercadopago`. And set the env `MERCADOPAGO_WEBHOOK_SECRET` with the secret +you set in the webhook creation. + +I recommend using +a [cloudflare tunnel]('https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/get-started/create-remote-tunnel/'), +or [ngrok]('https://ngrok.com/), or similar to expose your local server to the internet. + +### Login with Google + +Create a project in Google Cloud Platform and enable the Google+ API. Then create OAuth 2.0 credentials and set the envs +`GOOGLE_CLIENT_ID` and `GOOGLE_CLIENT_SECRET` with the values from the credentials. + +On the OAuth consent screen, set the authorized redirect URIs to `{your local env url}/accounts/google/login/callback/` + +### Once you have the envs set up, you can run the following commands: + +```sh python manage.py migrate whoami # copy the output python manage.py createsuperuser # paste the output as username, leave email empty, and set some password @@ -32,17 +73,11 @@ deactivate # if you want to deactivate the virtualenv ## Deploy -The project is meant to be deployed in AWS Lambda. `django-zappa` handles the -upload and configuration for us. You can see the config in -[`zappa_settings.json`](zappa_settings.json). - -To deploy from a local dev environment follow these steps: +### DEV -1. Setup your personal AWS credentials as the `[default]` profile (needed by - Zappa) - -2. Deploy with `zappa update dev` (or `zappa update prod`) +Just push to the `dev` branch and the pipeline will deploy to the dev environment. +### PROD > [!IMPORTANT] > In OS X you need to use a Docker image to have the same linux environment > as the one that runs in AWS Lambda to install the correct dependencies. @@ -54,6 +89,11 @@ To deploy from a local dev environment follow these steps: > zappashell> zappa update dev > ``` +Please don't push to the `main` branch directly. Create a PR and merge it on `dev` first. Then create a PR from `dev` to `main`. + +`TODO ongoing: The pipeline will deploy to the prod environment.` + +If for some horrible reason you need to push to `main` directly, PLEASE, make sure to backport the changes to `dev` afterwards. 3. Update the static files to S3: $ python manage.py collectstatic --settings=deprepagos.settings_prod diff --git a/deprepagos/local_settings.py.example b/deprepagos/local_settings.py.example index 355c291..a4ba1d0 100644 --- a/deprepagos/local_settings.py.example +++ b/deprepagos/local_settings.py.example @@ -1,40 +1,7 @@ import os -DEBUG = True EXTRA_INSTALLED_APPS = ['django_extensions'] -DATABASES = { - 'default': { - 'ENGINE': 'django.db.backends.postgresql', - 'NAME': os.environ.get('DB_DATABASE', 'deprepagos_development'), - 'USER': os.environ.get('DB_USER', ''), - 'PASSWORD': os.environ.get('DB_PASSWORD', ''), - 'HOST': os.environ.get('DB_HOST', '127.0.0.1'), - 'PORT': '5432', - } -} - PROJECT_ROOT = os.path.dirname(os.path.abspath(__file__)) STATIC_ROOT = os.path.join(PROJECT_ROOT, 'collected_static') - -PIPELINE = { - 'PIPELINE_ENABLED': False, - 'SHOW_ERRORS_INLINE': False, - 'COMPILERS': ( - 'libsasscompiler.LibSassCompiler', - ), - 'CSS_COMPRESSOR': 'pipeline.compressors.csshtmljsminify.CssHtmlJsMinifyCompressor', - 'STYLESHEETS': { - 'main': { - 'source_filenames': ( - 'scss/fuego.scss', - 'scss/global.scss', - ), - 'output_filename': 'css/main.css', - 'extra_context': { - 'media': 'screen,projection', - }, - }, - }, -} diff --git a/deprepagos/media/events/heros/no-image.jpg b/deprepagos/media/events/heros/no-image.jpg index 8d19223..a40404b 100644 Binary files a/deprepagos/media/events/heros/no-image.jpg and b/deprepagos/media/events/heros/no-image.jpg differ diff --git a/deprepagos/settings.py b/deprepagos/settings.py index 52e4c5d..928754c 100644 --- a/deprepagos/settings.py +++ b/deprepagos/settings.py @@ -1,22 +1,27 @@ -""" -Django settings for deprepagos project. +import os +from pathlib import Path -Generated by 'django-admin startproject' using Django 3.2.9. +import django +from django.utils.encoding import smart_str -For more information on this file, see -https://docs.djangoproject.com/en/3.2/topics/settings/ +django.utils.encoding.smart_text = smart_str -For the full list of settings and their values, see -https://docs.djangoproject.com/en/3.2/ref/settings/ -""" +import django.utils.translation as original_translation +from django.utils.translation import gettext_lazy -from pathlib import Path -import os +original_translation.ugettext_lazy = gettext_lazy + +from django.utils.encoding import force_str + +django.utils.encoding.force_text = force_str + +from dotenv import load_dotenv + +load_dotenv() # take environment variables # 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/3.2/howto/deployment/checklist/ @@ -26,6 +31,7 @@ # SECURITY WARNING: don't run with debug turned on in production! DEBUG = os.environ.get('DEBUG', 'True') == 'True' +ENV = os.environ.get('ENV', 'local') ALLOWED_HOSTS = [ '127.0.0.1', @@ -38,19 +44,35 @@ APP_URL = os.environ.get('APP_URL', 'http://localhost:8000') # Application definition +AUTHENTICATION_BACKENDS = [ + # Needed to login by username in Django admin, regardless of `allauth` + 'django.contrib.auth.backends.ModelBackend', + + # `allauth` specific authentication methods, such as login by email + 'allauth.account.auth_backends.AuthenticationBackend', + +] INSTALLED_APPS = [ 'django.contrib.auth', 'django.contrib.contenttypes', + 'django.contrib.humanize', 'django.contrib.sessions', 'django.contrib.messages', 'django.contrib.staticfiles', 'django.contrib.admin', - 'pipeline', 'bootstrap5', 'django_inlinecss', 'django_s3_storage', 'auditlog', + 'allauth', + 'allauth.account', + + 'allauth.socialaccount', + 'allauth.socialaccount.providers.google', + 'import_export', + + 'user_profile.apps.UserProfileConfig', 'tickets.apps.TicketsConfig', 'events.apps.EventsConfig', @@ -65,6 +87,10 @@ 'django.contrib.messages.middleware.MessageMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware', 'auditlog.middleware.AuditlogMiddleware', + "allauth.account.middleware.AccountMiddleware", + 'tickets.middleware.ProfileCompletionMiddleware', + 'tickets.middleware.DeviceDetectionMiddleware' + ] ROOT_URLCONF = 'deprepagos.urls' @@ -72,7 +98,11 @@ TEMPLATES = [ { 'BACKEND': 'django.template.backends.django.DjangoTemplates', - 'DIRS': [], + 'DIRS': [ + os.path.join(BASE_DIR, 'tickets/templates'), + os.path.join(BASE_DIR, 'user_profile/templates'), + + ], 'APP_DIRS': True, 'OPTIONS': { 'context_processors': [ @@ -81,6 +111,10 @@ 'django.contrib.auth.context_processors.auth', 'django.contrib.messages.context_processors.messages', 'utils.context_processors.current_event', + 'utils.context_processors.app_url', + 'utils.context_processors.chatwoot_token', + 'utils.context_processors.env', + 'utils.context_processors.chatwoot_identifier_hash', ], }, }, @@ -88,7 +122,6 @@ WSGI_APPLICATION = 'deprepagos.wsgi.application' - # Database # https://docs.djangoproject.com/en/3.2/ref/settings/#databases @@ -99,11 +132,10 @@ 'USER': os.environ.get('DB_USER', 'mauro'), 'PASSWORD': os.environ.get('DB_PASSWORD', ''), 'HOST': os.environ.get('DB_HOST', '127.0.0.1'), - 'PORT': '5432', + 'PORT': os.environ.get('DB_PORT', '5432'), } } - # Password validation # https://docs.djangoproject.com/en/3.2/ref/settings/#auth-password-validators @@ -122,7 +154,6 @@ }, ] - # Internationalization # https://docs.djangoproject.com/en/3.2/topics/i18n/ @@ -136,13 +167,12 @@ USE_TZ = True - # Default primary key field type # https://docs.djangoproject.com/en/3.2/ref/settings/#default-auto-field DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' # Static files (CSS, JavaScript, Images) -# https://docs.djangoproject.com/en/3.2/howto/static-files/ +# https://docs.djangoproject.com/en/4.2/howto/static-files/ PROJECT_ROOT = os.path.dirname(os.path.abspath(__file__)) STATIC_URL = '/static/' @@ -151,68 +181,55 @@ MEDIA_URL = '/media/' MEDIA_ROOT = os.path.join(PROJECT_ROOT, 'media') -STATICFILES_STORAGE = 'pipeline.storage.PipelineManifestStorage' STATICFILES_FINDERS = ( 'django.contrib.staticfiles.finders.FileSystemFinder', 'django.contrib.staticfiles.finders.AppDirectoriesFinder', - 'pipeline.finders.PipelineFinder', ) -PIPELINE = { - 'PIPELINE_ENABLED': True, - 'SHOW_ERRORS_INLINE': True, - 'COMPILERS': ( - 'libsasscompiler.LibSassCompiler', - ), - 'CSS_COMPRESSOR': 'pipeline.compressors.csshtmljsminify.CssHtmlJsMinifyCompressor', - 'STYLESHEETS': { - 'main': { - 'source_filenames': ( - 'scss/fuego.scss', - 'scss/global.scss', - ), - 'output_filename': 'css/main.css', - 'extra_context': { - 'media': 'screen,projection', - }, - }, - }, -} - MERCADOPAGO = { - # 'PUBLIC_KEY': 'TEST-320090f7-f283-4123-9d0a-ddcc5dca7652', # mauros@gmail.com - # 'ACCESS_TOKEN': 'TEST-3993191188804171-120900-49b84931b82af80f4e67442917d5a311-2309703', - # 'PUBLIC_KEY': 'TEST-adcf53df-bf76-4579-8f85-e3e1ef658c1c', # - # 'ACCESS_TOKEN': 'TEST-8395362091404017-102216-655293c3d37f873676196ce190a66889-663579293', - 'PUBLIC_KEY': 'TEST-467cbbca-1aac-4d7f-be0b-53bcf92a3064', # test_user_82107219@testuser.com / qatest8011 - 'ACCESS_TOKEN': 'TEST-6630578586763408-121117-afe84675e0d0a70b7c67a1ade5909b2c-1037325933', + 'PUBLIC_KEY': os.environ.get('MERCADOPAGO_PUBLIC_KEY'), + 'ACCESS_TOKEN': os.environ.get('MERCADOPAGO_ACCESS_TOKEN'), + 'WEBHOOK_SECRET': os.environ.get('MERCADOPAGO_WEBHOOK_SECRET') } -EMAIL_HOST = 'smtp.mailtrap.io' -EMAIL_HOST_USER = '1e47278bc26919' -EMAIL_HOST_PASSWORD = '1e355fb5adb1fd' -EMAIL_PORT = '2525' +EMAIL_HOST = os.environ.get('EMAIL_HOST') +EMAIL_HOST_USER = os.environ.get('EMAIL_HOST_USER') +EMAIL_HOST_PASSWORD = os.environ.get('EMAIL_HOST_PASSWORD') +EMAIL_PORT = os.environ.get('EMAIL_PORT') EMAIL_USE_TLS = True -DEFAULT_FROM_EMAIL = 'Fuego Austral ' +DEFAULT_FROM_EMAIL = os.environ.get('DEFAULT_FROM_EMAIL', 'Fuego Austral ').replace('"', + '').replace( + "'", '') TEMPLATED_EMAIL_TEMPLATE_DIR = 'emails/' TEMPLATED_EMAIL_FILE_EXTENSION = 'html' - - -# user comprador {"id":1037327132,"nickname":"TETE9670391","password":"qatest8330","site_status":"active","email":"test_user_43578812@testuser.com"}% -# user comprador {"id":1037346624,"nickname":"TETE9234065","password":"qatest9033","site_status":"active","email":"test_user_72163657@testuser.com"}% +TWILIO_ACCOUNT_SID = os.environ.get('TWILIO_ACCOUNT_SID', '') +TWILIO_AUTH_TOKEN = os.environ.get('TWILIO_AUTH_TOKEN', '') +TWILIO_VERIFY_SERVICE_SID = os.environ.get('TWILIO_VERIFY_SERVICE_SID', '') + +SOCIALACCOUNT_PROVIDERS = { + 'google': { + 'SCOPE': [ + 'email', + ], + 'APP': { + 'client_id': os.environ.get('GOOGLE_CLIENT_ID', ''), + 'secret': os.environ.get('GOOGLE_SECRET', '') + } + } +} try: from deprepagos.local_settings import * + INSTALLED_APPS.extend(EXTRA_INSTALLED_APPS) except ImportError: # using print and not log here as logging is yet not configured print('local settings not found') pass - LOGGING = { 'version': 1, 'disable_existing_loggers': False, @@ -233,3 +250,51 @@ }, }, } + +# ENABLE DEBUG LOGGING FOR DATABASE QUERIES +# if ENV == 'local': +# LOGGING['loggers']['django.db'] = { +# 'level': 'DEBUG' +# } + + +AUTH_PASSWORD_VALIDATORS = [ + { + "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", + "OPTIONS": { + "min_length": 8, + }, + } +] + +# Email settings +ACCOUNT_EMAIL_REQUIRED = True +ACCOUNT_EMAIL_VERIFICATION = 'mandatory' +ACCOUNT_USERNAME_REQUIRED = False +ACCOUNT_USER_MODEL_USERNAME_FIELD = None + +# Login settings +LOGIN_REDIRECT_URL = 'mi_fuego' +LOGIN_URL = '/mi-fuego/login/' +ACCOUNT_LOGOUT_REDIRECT_URL = APP_URL +ACCOUNT_AUTHENTICATION_METHOD = 'email' # 'username_email', 'username' +ACCOUNT_EMAIL_CONFIRMATION_EXPIRE_DAYS = 3 + +SOCIALACCOUNT_EMAIL_AUTHENTICATION = True +SOCIALACCOUNT_LOGIN_ON_GET = True +ACCOUNT_LOGOUT_ON_GET = True +ACCOUNT_CONFIRM_EMAIL_ON_GET = True +ACCOUNT_LOGIN_ON_EMAIL_CONFIRMATION = True +ACCOUNT_EMAIL_CONFIRMATION_ANONYMOUS_REDIRECT_URL = APP_URL + '/mi-fuego/verification-congrats/' +ACCOUNT_DEFAULT_HTTP_PROTOCOL = ( + "http" if "localhost" in APP_URL or "127.0.0.1" in APP_URL else "https" +) + +CSRF_TRUSTED_ORIGINS = [APP_URL] + +CHATWOOT_TOKEN = os.environ.get('CHATWOOT_TOKEN') +CHATWOOT_IDENTITY_VALIDATION = os.environ.get('CHATWOOT_IDENTITY_VALIDATION') + +SECRET = os.environ.get('SECRET') + +MOCK_PHONE_VERIFICATION = False diff --git a/deprepagos/settings_dev.py b/deprepagos/settings_dev.py index 99dfd43..602383a 100644 --- a/deprepagos/settings_dev.py +++ b/deprepagos/settings_dev.py @@ -1,11 +1,13 @@ -from deprepagos.settings import * +from deprepagos.settings import * # noqa -CSRF_TRUSTED_ORIGINS = ['eventos.fuegoaustral.org'] +MOCK_PHONE_VERIFICATION = True + +CSRF_TRUSTED_ORIGINS = ['dev.fuegoaustral.org'] DEFAULT_FILE_STORAGE = 'django_s3_storage.storage.S3Storage' -STATICFILES_STORAGE = 'deprepagos.storages.StaticS3PipelineManifestStorage' +STATICFILES_STORAGE = 'django_s3_storage.storage.StaticS3Storage' -AWS_STORAGE_BUCKET_NAME = 'faticketera-zappa-dev' +AWS_STORAGE_BUCKET_NAME = 'faticketera-zappa-dev' AWS_QUERYSTRING_AUTH = False AWS_S3_BUCKET_NAME_STATIC = 'faticketera-zappa-dev' @@ -13,15 +15,3 @@ AWS_S3_BUCKET_NAME = 'faticketera-zappa-dev' AWS_S3_BUCKET_AUTH = False - -EMAIL_HOST = os.environ.get('EMAIL_HOST') -EMAIL_HOST_USER = os.environ.get('EMAIL_HOST_USER') -EMAIL_HOST_PASSWORD = os.environ.get('EMAIL_HOST_PASSWORD') -EMAIL_PORT = os.environ.get('EMAIL_PORT') -EMAIL_USE_TLS = True -DEFAULT_FROM_EMAIL = os.environ.get('DEFAULT_FROM_EMAIL', DEFAULT_FROM_EMAIL) - -MERCADOPAGO = { - 'PUBLIC_KEY': os.environ.get('MERCADOPAGO_PUBLIC_KEY'), - 'ACCESS_TOKEN': os.environ.get('MERCADOPAGO_ACCESS_TOKEN'), -} diff --git a/deprepagos/settings_prod.py b/deprepagos/settings_prod.py index 435d986..d034c42 100644 --- a/deprepagos/settings_prod.py +++ b/deprepagos/settings_prod.py @@ -1,13 +1,12 @@ from deprepagos.settings import * -DEBUG=False CSRF_TRUSTED_ORIGINS = ['eventos.fuegoaustral.org'] DEFAULT_FILE_STORAGE = 'django_s3_storage.storage.S3Storage' -STATICFILES_STORAGE = 'deprepagos.storages.StaticS3PipelineManifestStorage' +STATICFILES_STORAGE = 'django_s3_storage.storage.StaticS3Storage' -AWS_STORAGE_BUCKET_NAME = 'faticketera-zappa-prod' +AWS_STORAGE_BUCKET_NAME = 'faticketera-zappa-prod' AWS_QUERYSTRING_AUTH = False AWS_S3_BUCKET_NAME_STATIC = 'faticketera-zappa-prod' @@ -15,18 +14,3 @@ AWS_S3_BUCKET_NAME = 'faticketera-zappa-prod' AWS_S3_BUCKET_AUTH = False - -EMAIL_HOST = os.environ.get('EMAIL_HOST') -EMAIL_HOST_USER = os.environ.get('EMAIL_HOST_USER') -EMAIL_HOST_PASSWORD = os.environ.get('EMAIL_HOST_PASSWORD') -EMAIL_PORT = os.environ.get('EMAIL_PORT') -EMAIL_USE_TLS = True -DEFAULT_FROM_EMAIL = os.environ.get('DEFAULT_FROM_EMAIL', DEFAULT_FROM_EMAIL) - -MERCADOPAGO = { - 'PUBLIC_KEY': os.environ.get('MERCADOPAGO_PUBLIC_KEY'), - 'ACCESS_TOKEN': os.environ.get('MERCADOPAGO_ACCESS_TOKEN'), -} - -# override development settings -PIPELINE['PIPELINE_ENABLED'] = True diff --git a/deprepagos/storages.py b/deprepagos/storages.py deleted file mode 100644 index 685a909..0000000 --- a/deprepagos/storages.py +++ /dev/null @@ -1,12 +0,0 @@ -from django.conf import settings -from django.contrib.staticfiles.storage import ManifestFilesMixin -from pipeline.storage import PipelineMixin -from storages.backends.s3boto3 import S3Boto3Storage, S3StaticStorage - - -class StaticS3PipelineManifestStorage(PipelineMixin, S3StaticStorage): - pass - - -class S3PipelineManifestStorage(PipelineMixin, S3Boto3Storage): - pass diff --git a/deprepagos/urls.py b/deprepagos/urls.py index 6413002..79c5562 100644 --- a/deprepagos/urls.py +++ b/deprepagos/urls.py @@ -3,9 +3,26 @@ from django.contrib import admin from django.urls import path, include +from tickets.admin import admin_caja_view, email_has_account, admin_caja_order_view, admin_direct_tickets_view, \ + admin_direct_tickets_buyer_view, admin_direct_tickets_congrats_view + urlpatterns = [ + path('admin/caja/', admin_caja_view, name='admin_caja_view'), + + path('admin/caja/order//', admin_caja_order_view, name='admin_caja_order_view'), + path('admin/caja/email-has-account/', email_has_account, name='email_has_account'), + + path('admin/direct_tickets/', admin_direct_tickets_view, name='admin_direct_tickets_view'), + path('admin/direct_tickets/buyer/', admin_direct_tickets_buyer_view, name='admin_direct_tickets_buyer_view'), + path('admin/direct_tickets/congrats//', admin_direct_tickets_congrats_view, + name='admin_direct_tickets_congrats_view'), + path('admin/', admin.site.urls), + + path('mi-fuego/', include('allauth.urls')), + path('mi-fuego/', include('user_profile.urls')), path('', include('tickets.urls')), + ] if settings.DEBUG == True: diff --git a/events/admin.py b/events/admin.py index 27b8c45..266c2b2 100644 --- a/events/admin.py +++ b/events/admin.py @@ -4,33 +4,59 @@ class EventAdmin(admin.ModelAdmin): - list_display = ('name', 'active', 'start', 'end', 'max_tickets', ) # add: transfers active - list_filter = ('active', ) - search_fields = ('name', 'titke' ) + list_display = ( + "name", + "active", + "start", + "end", + "max_tickets", + ) # add: transfers active + list_filter = ("active",) + search_fields = ("name",) fieldsets = [ ( None, { - 'fields': ['active', 'name', 'has_volunteers', 'start', 'end', 'max_tickets', 'transfers_enabled_until', 'show_multiple_tickets', ] + "fields": [ + "active", + "name", + "start", + "end", + "max_tickets", + "max_tickets_per_order", + "transfers_enabled_until", + "show_multiple_tickets", + "has_volunteers", + "volunteers_enabled_until", + ] }, ), ( - 'Homepage', + "Homepage", { - 'fields': ['header_image', 'header_bg_color', 'title', 'description', ] + "fields": [ + "header_image", + "header_bg_color", + "title", + "description", + ] }, ), ( - 'Buy Ticket Form', + "Buy Ticket Form", { - 'fields': ['pre_ticket_form_info', ] + "fields": [ + "pre_ticket_form_info", + ] }, ), ( - 'Email', + "Email", { - 'fields': ['email_info', ] + "fields": [ + "email_info", + ] }, ), ] diff --git a/events/migrations/0009_event_max_tickets_per_order.py b/events/migrations/0009_event_max_tickets_per_order.py new file mode 100644 index 0000000..64eac79 --- /dev/null +++ b/events/migrations/0009_event_max_tickets_per_order.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.15 on 2024-08-19 23:20 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('events', '0008_add_header_image_to_first_event'), + ] + + operations = [ + migrations.AddField( + model_name='event', + name='max_tickets_per_order', + field=models.IntegerField(default=5), + ), + ] diff --git a/events/migrations/0010_event_volunteers_enabled_until.py b/events/migrations/0010_event_volunteers_enabled_until.py new file mode 100644 index 0000000..2996fb5 --- /dev/null +++ b/events/migrations/0010_event_volunteers_enabled_until.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.15 on 2024-10-25 18:52 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('events', '0009_event_max_tickets_per_order'), + ] + + operations = [ + migrations.AddField( + model_name='event', + name='volunteers_enabled_until', + field=models.DateTimeField(blank=True, null=True), + ), + ] diff --git a/events/models.py b/events/models.py index e637c00..50d3f0c 100644 --- a/events/models.py +++ b/events/models.py @@ -1,6 +1,7 @@ from django.db import models from django.db.models import Count, Sum, Q from django.forms import ValidationError +from django.utils import timezone from auditlog.registry import auditlog @@ -14,12 +15,16 @@ class Event(BaseModel): start = models.DateTimeField() end = models.DateTimeField() max_tickets = models.IntegerField(blank=True, null=True) + max_tickets_per_order = models.IntegerField(default=5) transfers_enabled_until = models.DateTimeField() - show_multiple_tickets = models.BooleanField(default=False, help_text="If unchecked, only the chepeast ticket will be shown.") + volunteers_enabled_until = models.DateTimeField(blank=True, null=True) + show_multiple_tickets = models.BooleanField(default=False, + help_text="If unchecked, only the chepeast ticket will be shown.") # homepage header_image = models.ImageField(upload_to='events/heros', help_text=u"Dimensions: 1666px x 500px") - header_bg_color = models.CharField(max_length=7, help_text='e.g. "#fc0006". The color of the background to fill on bigger screens.') + header_bg_color = models.CharField(max_length=7, + help_text='e.g. "#fc0006". The color of the background to fill on bigger screens.') title = models.TextField() description = models.TextField() @@ -42,22 +47,39 @@ def clean(self, *args, **kwargs): qs = Event.objects.exclude(pk=self.pk).filter(active=True) if qs.exists(): raise ValidationError({ - 'active': ValidationError('Only one event can be active at the same time. Please set the other events as inactive before saving.', code='not_unique'), + 'active': ValidationError( + 'Only one event can be active at the same time. Please set the other events as inactive before saving.', + code='not_unique'), }) return super().clean(*args, **kwargs) def tickets_remaining(self): from tickets.models import Order + if self.max_tickets: tickets_sold = (Order.objects - .filter(ticket_type__event=self) - .filter(status=Order.OrderStatus.CONFIRMED) - .annotate(num_tickets=Count('ticket')) - .aggregate(tickets_sold=Sum('num_tickets') - ))['tickets_sold'] or 0 + .filter(order_tickets__ticket_type__event=self) + .filter(status=Order.OrderStatus.CONFIRMED) + .annotate(num_tickets=Sum('order_tickets__quantity')) + .aggregate(tickets_sold=Sum('num_tickets')) + )['tickets_sold'] or 0 return self.max_tickets - tickets_sold else: return 999999999 # extra high number (easy hack) + def volunteer_period(self): + if self.end < timezone.now(): + return False + if self.volunteers_enabled_until and self.volunteers_enabled_until < timezone.now(): + return False + return True + + def transfer_period(self): + if self.end < timezone.now(): + return False + if self.transfers_enabled_until and self.transfers_enabled_until < timezone.now(): + return False + return True + auditlog.register(Event) diff --git a/requirements-freeze.txt b/requirements-freeze.txt deleted file mode 100644 index 8fa2fc4..0000000 --- a/requirements-freeze.txt +++ /dev/null @@ -1,63 +0,0 @@ -argcomplete==1.12.3 -asgiref==3.4.1 -auditlog3==1.0.1 -beautifulsoup4==4.10.0 -boto3==1.20.24 -botocore==1.23.24 -certifi==2021.10.8 -cfn-flip==1.3.0 -charset-normalizer==2.0.9 -click==8.0.3 -css-html-js-minify==2.5.5 -cssutils==2.3.0 -Django==3.2.9 -django-auditlog==0.4.7 -django-bootstrap-v5==1.0.7 -django-extensions==3.1.5 -django-formset-js-improved==0.5.0.2 -django-inlinecss==0.3.0 -django-jquery-js==3.1.1 -django-jsonfield==1.4.1 -django-pipeline==2.0.7 -django-render-block==0.8.1 -django-s3-storage==0.15.0 -django-storages==1.14.4 -django-templated-email==3.0.1 -durationpy==0.5 -future==0.18.2 -hjson==3.0.2 -idna==3.3 -jmespath==0.10.0 -jsonfield==3.1.0 -kappa==0.6.0 -libsass==0.21.0 -libsasscompiler==0.1.9 -mercadopago==2.0.8 -pep517==0.12.0 -pillow==10.4.0 -pip-tools==6.4.0 -placebo==0.10.0 -psycopg2-binary -pynliner==0.8.0 -pypng==0.20220715.0 -python-dateutil==2.6.0 -python-slugify==5.0.2 -pytz==2021.3 -PyYAML==6.0 -qrcode==7.4.2 -requests==2.26.0 -s3transfer==0.5.0 -six==1.16.0 -soupsieve==2.3.1 -sqlparse==0.4.2 -text-unidecode==1.3 -toml==0.10.2 -tomli==2.0.0 -tqdm==4.62.3 -troposphere==3.1.1 -typing_extensions==4.12.2 -urllib3==1.26.7 -Werkzeug==2.0.2 -wsgi-request-logger==0.4.6 -zappa==0.54.1 -zappa-django-utils==0.4.1 diff --git a/requirements.txt b/requirements.txt index 7053192..e113cc7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,28 +1,77 @@ -asgiref==3.4.1 +aiohappyeyeballs==2.3.6 +aiohttp==3.10.3 +aiohttp-retry==2.8.3 +aiosignal==1.3.1 +argcomplete==3.5.0 +asgiref==3.8.1 +asn1crypto==1.5.1 +async-timeout==4.0.3 +attrs==24.2.0 +auditlog3==1.0.1 beautifulsoup4==4.10.0 +boto3==1.34.161 +botocore==1.34.161 certifi==2021.10.8 +cffi==1.17.0 +cfn-flip==1.3.0 charset-normalizer==2.0.9 -Django==3.2.9 +click==8.1.7 +cryptography==43.0.0 +css-html-js-minify==2.5.5 +cssutils==2.11.1 +Django==4.2.15 +django-allauth==64.0.0 django-bootstrap-v5==1.0.7 -django-pipeline==2.0.7 +django-extensions==3.2.3 +django-inlinecss==0.3.0 +django-render-block==0.10 +django-s3-storage==0.15.0 +django-storages==1.14.4 +django-templated-email==3.0.1 +durationpy==0.7 +frozenlist==1.4.1 +future==1.0.0 +hjson==3.1.0 idna==3.3 +jmespath==1.0.1 +jsonfield==3.1.0 +kappa==0.6.0 libsass==0.21.0 libsasscompiler==0.1.9 +MarkupSafe==2.1.5 mercadopago==2.0.8 -psycopg2-binary +more-itertools==10.4.0 +multidict==6.0.5 +oauthlib==3.2.2 +pillow==10.4.0 +placebo==0.9.0 +psycopg2-binary==2.9.9 +pycparser==2.22 +PyJWT==2.9.0 +pynliner==0.8.0 +pypng==0.20220715.0 +python-dateutil==2.9.0.post0 +python-slugify==8.0.4 pytz==2021.3 +PyYAML==6.0.2 +qrcode==7.4.2 requests==2.26.0 +requests-oauthlib==2.0.0 +s3transfer==0.10.2 six==1.16.0 soupsieve==2.3.1 sqlparse==0.4.2 +text-unidecode==1.3 +toml==0.10.2 +tqdm==4.66.5 +troposphere==4.8.1 +twilio==9.2.3 +typing_extensions==4.12.2 urllib3==1.26.7 -django-inlinecss<0.4 -django-templated-email<3.1 -pillow -qrcode -css-html-js-minify -django-storages -auditlog3 -jsonfield -django_s3_storage -zappa-django-utils +Werkzeug==3.0.3 +wsgi-request-logger==0.4.6 +yarl==1.9.4 +zappa==0.59.0 +zappa-django-utils==0.4.1 +python-dotenv>=1.0.1,<1.1 +django-import-export[xlsx]>=4.2.0,<4.3 \ No newline at end of file diff --git a/tickets/admin.py b/tickets/admin.py index 5576ac5..3f82152 100644 --- a/tickets/admin.py +++ b/tickets/admin.py @@ -1,206 +1,374 @@ -import csv - -from django.conf import settings -from django.contrib import admin, messages -from django.db.models import Count, Q -from django.http import HttpResponse +import json +import uuid +from urllib.parse import urlencode + +from allauth.account.forms import ResetPasswordForm +from allauth.account.models import EmailAddress +from django.contrib import admin +from django.contrib.admin.views.decorators import staff_member_required +from django.contrib.auth.decorators import permission_required +from django.contrib.auth.models import User +from django.db import transaction +from django.http import HttpResponse, JsonResponse +from django.shortcuts import render, redirect from django.urls import reverse -from django.utils.safestring import mark_safe -from django.utils.translation import ngettext - -from .models import Ticket, TicketType, Order, Coupon, TicketTransfer +from import_export import resources +from import_export.admin import ImportExportModelAdmin +from deprepagos import settings +from events.models import Event +from utils.direct_sales import direct_sales_existing_user, direct_sales_new_user +from .forms import TicketPurchaseForm +from .models import TicketType, Order, OrderTicket, NewTicket, NewTicketTransfer, DirectTicketTemplate, \ + DirectTicketTemplateStatus +from .views import webhooks admin.site.site_header = 'Bonos de Fuego Austral' -class TicketAdmin(admin.ModelAdmin): - list_display = ('order_id', 'get_status', 'get_event', 'first_name', 'last_name', 'email', 'phone', 'dni', 'price', 'key', ) - list_filter = ('order__ticket_type__event', 'order__status', 'volunteer_ranger', 'volunteer_umpalumpa', 'volunteer_transmutator', ) - search_fields = ('first_name', 'last_name', 'key', ) - actions = ['export', 'resend_email', ] - readonly_fields = ('key', ) - - def get_queryset(self, request): - return super(TicketAdmin,self).get_queryset(request).select_related('order__ticket_type__event') - - @admin.action(description='Exportar tickets seleccionados') - def export(self, request, queryset): - response = HttpResponse( - content_type='text/csv', - headers={'Content-Disposition': 'attachment; filename="tickets.csv"'}, - ) - - writer = csv.writer(response) - writer.writerow(['Nombre', 'Apellido', 'Email', 'Teléfono', 'DNI', - 'Precio', 'Orden #', 'Voluntario', 'Ranger', 'Transmutador', - 'Umpalumpa', 'Ticket #', ]) - - for ticket in queryset: - writer.writerow([ - ticket.first_name, - ticket.last_name, - ticket.email, - ticket.phone, - ticket.dni, - ticket.price, - ticket.order_id, - ticket.get_volunteer_display(), - 'Sí' if ticket.volunteer_ranger else 'No', - 'Sí' if ticket.volunteer_transmutator else 'No', - 'Sí' if ticket.volunteer_umpalumpa else 'No', - ticket.key, - ]) - - return response - - @admin.action(description='Volver a enviar email') - def resend_email(self, request, queryset): - - for ticket in queryset: - ticket.send_email() - count = queryset.count() - self.message_user( - request, - ngettext( - "Enviado %d email de ticket.", - "Enviados %d emails de ticket.", - count, - ) - % count, - messages.SUCCESS, - ) - - @admin.display(ordering='order__status', description='status') - def get_status(self, obj): - return obj.order.get_status_display() - - @admin.display(ordering='order__ticket_type__event__event_id', description='Event') - def get_event(self, obj): - return obj.order.ticket_type.event - -class TicketTypeAdmin(admin.ModelAdmin): - list_display = ('name', 'event', 'tickets_sold', 'ticket_count', 'price', 'price_with_coupon', 'date_from', 'date_to') - list_filter = ('event', ) - - def get_queryset(self, *args, **kwargs): - qs = super().get_queryset(*args, **kwargs) - qs = qs.annotate(tickets_sold=Count('order__ticket', filter=Q(order__status=Order.OrderStatus.CONFIRMED))) - return qs - - def tickets_sold(self, obj): - return obj.tickets_sold +@staff_member_required +@permission_required("tickets.can_sell_tickets") +def email_has_account(request): + if request.method == 'POST': + + data = json.loads(request.body) + email = data.get('email') + + user = User.objects.filter(email=email).first() + if user: + if user.profile.profile_completion == 'COMPLETE': + return JsonResponse({ + 'first_name': user.first_name, + 'last_name': user.last_name, + 'phone': user.profile.phone, + 'document_type': user.profile.document_type, + 'document_number': user.profile.document_number, + + }) + return HttpResponse(status=206) + else: + return HttpResponse(status=204) + return HttpResponse(status=405) + + +@staff_member_required +@permission_required("tickets.can_sell_tickets") +def admin_caja_view(request): + events = Event.objects.all() + default_event = Event.objects.filter(active=True).first() + ticket_types = TicketType.objects.filter(event_id=default_event.id) if default_event else None + + form = TicketPurchaseForm(event=default_event) + + if request.method == 'POST': + selected_event_id = request.POST.get('event') + action = request.POST.get('action') + + print(action) + if action == "event" and selected_event_id: + ticket_types = TicketType.objects.filter(event_id=selected_event_id) + default_event = Event.objects.get(id=selected_event_id) + form = TicketPurchaseForm(request.POST, event=default_event) + elif action == "order": + form = TicketPurchaseForm(request.POST, event=default_event) + if form.is_valid(): + total_amount = 0 + tickets_quantity = [] + + for ticket in form.cleaned_data: + if ticket.startswith('ticket_quantity_'): + ticket_type_id = ticket.split('_')[2] + ticket_type = TicketType.objects.get(id=ticket_type_id) + quantity = form.cleaned_data[ticket] + total_amount += ticket_type.price * quantity + tickets_quantity.append({ + 'ticket_type': ticket_type, + 'quantity': quantity + }) + + user = User.objects.filter(email=form.cleaned_data['email']).first() + new_user = False + with transaction.atomic(): + if not user: + new_user = True + user = User.objects.create_user( + username=str(uuid.uuid4()), + email=form.cleaned_data['email'], + first_name=form.cleaned_data['first_name'], + last_name=form.cleaned_data['last_name'], + ) + user.profile.phone = form.cleaned_data['phone'] + user.profile.document_type = form.cleaned_data['document_type'] + user.profile.document_number = form.cleaned_data['document_number'] + user.profile.profile_completion = 'COMPLETE' + user.save() + + EmailAddress.objects.create( + user=user, + email=form.cleaned_data['email'], + verified=True, + primary=True + ) + + reset_form = ResetPasswordForm(data={'email': user.email}) + + if reset_form.is_valid(): + reset_form.save( + subject_template_name='account/email/password_reset_subject.txt', + email_template_name='account/email/password_reset_email.html', + from_email=settings.DEFAULT_FROM_EMAIL, + request=None, # Not needed in this context + use_https=False, # Use True if your site uses HTTPS + html_email_template_name=None, + extra_email_context=None + ) + + order = Order( + first_name=user.first_name, + last_name=user.last_name, + email=user.email, + phone=user.profile.phone, + dni=user.profile.document_number, + amount=total_amount, + status=Order.OrderStatus.CONFIRMED, + event=default_event, + user=user, + order_type=form.cleaned_data['order_type'], + donation_art=0, + donation_venue=0, + donation_grant=0, + notes=form.cleaned_data['notes'], + generated_by=request.user + ) + order.save() + + order_tickets = [ + OrderTicket( + order=order, + ticket_type=ticket_type, + quantity=quantity + ) + for ticket_quantity in tickets_quantity + if (ticket_type := ticket_quantity['ticket_type']) and ( + quantity := ticket_quantity['quantity']) > 0 + ] + if order_tickets: + OrderTicket.objects.bulk_create(order_tickets) + + new_minted_tickets = webhooks.mint_tickets(order) + Order.objects.get(key=order.key).send_confirmation_email() + + # Build the base URL + base_url = reverse('admin_caja_order_view', args=[order.key]) + + # Define query parameters + query_params = {'new_user': new_user} + + # Construct the full URL with query parameters + url = f"{base_url}?{urlencode(query_params)}" + + return redirect(url) + + context = { + 'events': events, + 'default_event': default_event, + 'ticket_types': ticket_types, + 'form': form, + } + return render(request, 'admin/admin_caja.html', context) + + +@staff_member_required +@permission_required("tickets.can_sell_tickets") +def admin_direct_tickets_view(request): + direct_ticket_summary = request.session.pop('direct_ticket_summary', {}) + events = Event.objects.filter(active=True).all() + default_event = events.first() + direct_tickets = DirectTicketTemplate.objects.filter(event_id=default_event.id, + status=DirectTicketTemplateStatus.AVAILABLE) if default_event else None + ticket_type = TicketType.objects.filter(event_id=default_event.id, + is_direct_type=True).first() if default_event else None + + if request.method == 'POST': + selected_event_id = request.POST.get('event') + action = request.POST.get('action') + + if action == "event" and selected_event_id: + ticket_type = TicketType.objects.filter(event_id=selected_event_id, + is_direct_type=True).first() if selected_event_id else None + default_event = Event.objects.get(id=selected_event_id) + + direct_tickets = DirectTicketTemplate.objects.filter(event_id=default_event.id, + status=DirectTicketTemplateStatus.AVAILABLE) if default_event else None + elif action == "order": + + email = request.POST.get('email') + notes = request.POST.get('notes') + ticket_amounts = {int(k.split('_')[2]): int(v) for k, v in request.POST.items() if + k.startswith('ticket_amount_')} + + order_type = request.POST.get('order_type') + + request.session['direct_ticket_summary'] = { + 'email': email, + 'notes': notes, + 'ticket_amounts': ticket_amounts, + 'order_type': order_type, + } + return redirect('admin_direct_tickets_buyer_view') + + context = { + 'ticket_type': ticket_type, + 'events': events, + 'default_event': default_event, + 'direct_tickets': direct_tickets, + } + return render(request, 'admin/admin_direct_tickets.html', context) + + +@staff_member_required +@permission_required("tickets.can_sell_tickets") +def admin_direct_tickets_buyer_view(request): + direct_ticket_summary = request.session['direct_ticket_summary'] + + email = direct_ticket_summary.get('email') + notes = direct_ticket_summary.get('notes') + ticket_amounts = direct_ticket_summary.get('ticket_amounts') + order_type = direct_ticket_summary.get('order_type') + + user = User.objects.filter(email=email).first() + + templates = DirectTicketTemplate.objects.filter(id__in=ticket_amounts.keys()).all() + + template_tickets = [] + for template in templates: + template_tickets.append({ + 'id': template.id, + 'name': template.name, + 'origin': template.origin, + 'amount': ticket_amounts.get(str(template.id), 0), + 'event_id': template.event.id + + }) + + if request.method == 'POST': + new_order_id = None + if user is None: + new_order_id = direct_sales_new_user(email, template_tickets, order_type, notes, request.user) + else: + new_order_id = direct_sales_existing_user(user, template_tickets, order_type, notes, request.user) + + return redirect('admin_direct_tickets_congrats_view', new_order_id=new_order_id) + + elif request.method == 'GET': + return render(request, 'admin/admin_direct_tickets_buyer.html', { + 'user': user, + 'email': email, + 'tickets': template_tickets, + 'notes': notes, + 'order_type': order_type + + }) + + +@staff_member_required +@permission_required("tickets.can_sell_tickets") +def admin_direct_tickets_congrats_view(request, new_order_id): + tickets = NewTicket.objects.filter(order_id=new_order_id).all() + order = Order.objects.get(id=new_order_id) + return render(request, 'admin/admin_direct_tickets_congrats.html', {'tickets': tickets, 'order': order}) + + +def admin_caja_order_view(request, order_key): + new_user = request.GET.get('new_user', True) # Default to True if not provided + new_user = new_user in ['true', 'True', True] + + order = Order.objects.get(key=order_key) + tickets = NewTicket.objects.filter(order=order).all() + + return render(request, 'admin/admin_caja_summary.html', { + 'order': order, + 'tickets': tickets, + 'new_user': new_user, + }) + - -class TicketInline(admin.StackedInline): - model = Ticket - show_change_link = True +class NewTicketInline(admin.StackedInline): + model = NewTicket extra = 0 class OrderAdmin(admin.ModelAdmin): - list_display = ('id', 'status', 'get_event', 'first_name', 'last_name', - 'ticket_type_link', 'ticket_count', 'amount', - 'donation_art', 'donation_venue', 'donation_grant') - list_filter = ('ticket_type__event', 'status', 'ticket_type', ) - search_fields = ('first_name', 'last_name', 'ticket__first_name', 'ticket__last_name', ) - inlines = [TicketInline, ] - actions = ['export'] - - def get_queryset(self, request): - return super(OrderAdmin,self).get_queryset(request).select_related('ticket_type__event') - - @admin.display(ordering='ticket_type__event__event_id', description='Event') - def get_event(self, obj): - return obj.ticket_type.event - - @admin.action(description='Exportar órdenes seleccionadas') - def export(self, request, queryset): - response = HttpResponse( - content_type='text/csv', - headers={'Content-Disposition': 'attachment; filename="órdenes.csv"'}, - ) - - writer = csv.writer(response) - writer.writerow(['#', 'Estado', 'Nombre', 'Apellido', 'Email', 'Teléfono', 'DNI', - 'Becas de Arte', 'Becas NTUM', 'Donaciones a La Sede', 'Valor Total', 'Cupón', - 'Tipo de Ticket', '# Tickets', ]) - - for order in queryset: - writer.writerow([ - order.id, - order.status, - order.first_name, - order.last_name, - order.email, - order.phone, - order.dni, - order.donation_art, - order.donation_grant, - order.donation_venue, - order.amount, - order.coupon, - order.ticket_type, - order.ticket_set.count(), - ]) - - return response - - def ticket_count(self, order): - return order.ticket_set.count() - - def ticket_type_link(self, order): - return mark_safe('{}'.format( - reverse("admin:tickets_tickettype_change", args=(order.ticket_type.pk,)), - order.ticket_type.name - )) - -class OrdersInline(admin.StackedInline): - model = Order - fields = ('first_name', 'last_name', 'email', 'amount', 'status', ) - show_change_link = True - extra = 0 + inlines = [NewTicketInline] + readonly_fields = ['key', ] -class CouponAdmin(admin.ModelAdmin): - list_display = ('token', 'ticket_type', 'get_event', 'max_tickets', 'tickets_remaining', 'display_url', ) - list_filter = ('ticket_type__event', 'ticket_type', ) - search_fields = ('token', 'ticket_type__name', ) - readonly_fields = ('tickets_remaining', 'display_url', ) - inlines = [OrdersInline, ] + +class DirectTicketTemplateResource(resources.ModelResource): class Meta: - model = Coupon - fields = ('display_url', ) + model = DirectTicketTemplate + fields = ['origin', 'name', 'amount'] + exclude = ('id') + force_init_instance = True - def get_queryset(self, request): - return super(CouponAdmin,self).get_queryset(request).select_related('ticket_type__event') + def __init__(self, *args, event, **kwargs): + super().__init__(*args, **kwargs) + self.event = event - @admin.display(ordering='ticket_type__event__event_id', description='Event') - def get_event(self, obj): - return obj.ticket_type.event + def before_save_instance(self, instance, row, **kwargs): + instance.event = self.event - def display_url(self, order): - return f"{getattr(settings, 'APP_URL')}/?coupon={order.token}" +@admin.register(DirectTicketTemplate) +class DirectTicketTemplateAdmin(ImportExportModelAdmin): + resource_classes = [DirectTicketTemplateResource] + list_display = ['id', 'origin', 'name', 'amount', 'status', 'event'] + list_display_links = ['id'] # El campo 'name' será el enlace a los detalles + list_filter = ['event__name'] # Filtro por evento + search_fields = ['event__name'] # Buscar por nombre y evento -class TicketTransferAdmin(admin.ModelAdmin): - fields = ('ticket', 'first_name', 'last_name', 'email', 'phone', 'dni', 'transferred', ) - list_filter = ('ticket__order__ticket_type__event', 'transferred', ) - list_display = ('first_name', 'last_name', 'ticket', 'get_event', 'transferred', ) + def get_import_resource_kwargs(self, request, *args, **kwargs): + kwargs = super().get_resource_kwargs(request, *args, **kwargs) + event = Event.objects.filter(active=True).first() + kwargs.update({"event": event}) + return kwargs - def get_queryset(self, request): - return super(TicketTransferAdmin,self).get_queryset(request).select_related('ticket__order__ticket_type__event') - @admin.display(ordering='ticket__order__ticket_type__event__event_id', description='Event') - def get_event(self, obj): - return obj.ticket.order.ticket_type.event +class TicketTypeAdmin(admin.ModelAdmin): + list_display = ['name', 'event', 'price', 'is_direct_type', 'ticket_count', 'date_from', 'date_to'] + list_filter = ['event__name', 'is_direct_type'] + search_fields = ['name', 'event__name'] + + +class NewTicketAdmin(admin.ModelAdmin): + list_display = ['owner', 'ticket_type', 'holder', 'order_id', 'event', 'created_at'] + list_filter = ['event__name', 'ticket_type__name', 'order__status'] + search_fields = [ + 'holder__first_name', + 'holder__last_name', + 'holder__email', + 'owner__first_name', + 'owner__last_name', + 'owner__email', + 'key', + ] + + +class NewTicketInline(admin.StackedInline): + model = NewTicket + extra = 0 + readonly_fields = ['key'] - class Meta: - model = TicketTransfer + +class OrderAdmin(admin.ModelAdmin): + list_display = ['id', 'first_name', 'last_name', 'email', 'phone', 'dni', 'amount', 'status', 'event', 'order_type', 'created_at'] + list_filter = ['event', 'status', 'order_type'] + search_fields = ['key', 'first_name', 'last_name', 'email', 'phone', 'dni'] + inlines = [NewTicketInline] + readonly_fields = ['key'] -admin.site.register(Ticket, TicketAdmin) -admin.site.register(TicketType, TicketTypeAdmin) admin.site.register(Order, OrderAdmin) -admin.site.register(Coupon, CouponAdmin) -admin.site.register(TicketTransfer, TicketTransferAdmin) +admin.site.register(TicketType, TicketTypeAdmin) +admin.site.register(NewTicket, NewTicketAdmin) +admin.site.register(NewTicketTransfer) diff --git a/tickets/email_crons.py b/tickets/email_crons.py new file mode 100644 index 0000000..b2dbf20 --- /dev/null +++ b/tickets/email_crons.py @@ -0,0 +1,351 @@ +import hashlib +import json +import logging +import time +from concurrent.futures import ThreadPoolExecutor, ALL_COMPLETED, wait + +from django.db import connection + +from events.models import Event +from tickets.models import MessageIdempotency +from utils.email import send_mail + +DASHES_LINE = '-' * 120 + + +def send_pending_actions_emails(event, context): + logging.info("Email cron job") + logging.info("==============") + logging.info("\n") + current_event = Event.objects.get(active=True) + send_pending_actions_emails_for_event(current_event) + + +def send_pending_actions_emails_for_event(current_event): + pending_transfers_recipient = get_pending_transfers_recipients(current_event) + pending_transfers_sender = get_pending_transfers_sender(current_event) + unsent_tickets = get_unsent_tickets(current_event) + + total_unsent_tickets = sum([holder.pending_to_share_tickets for holder in unsent_tickets]) + + logging.info(DASHES_LINE) + logging.info(f"| {len(pending_transfers_recipient)} Tickets waiting for recipient to create an account") + logging.info(f"| {total_unsent_tickets} Tickets waiting for the holder to share") + logging.info(DASHES_LINE) + logging.info( + f"{fibonacci_impares(5)} are the fibonacci uneven numbers < 30, we will send pending action reminders, on days since the action MOD 30, that are on that sequence") + logging.info(DASHES_LINE) + + start_time = time.perf_counter() + with (ThreadPoolExecutor() as executor): + futures = [ + executor.submit(send_sender_pending_transfers_reminder, transfer, current_event) + for transfer + in pending_transfers_sender + ] + [ + executor.submit(send_recipient_pending_transfers_reminder, transfer, current_event) + for transfer + in pending_transfers_recipient + ] + [ + executor.submit(send_unsent_tickets_reminder_email, unsent_ticket, current_event) + for unsent_ticket + in unsent_tickets + ] + + wait(futures, return_when=ALL_COMPLETED) + + total_emails_sent = sum([future.result()[0] for future in futures]) + total_sms_sent = sum([future.result()[1] for future in futures]) + + logging.info(DASHES_LINE) + logging.info( + f"Process time: {time.perf_counter() - start_time:.4f} seconds. Emails sent: {total_emails_sent}. SMS sent: {total_sms_sent}") + logging.info(DASHES_LINE) + + +def send_recipient_pending_transfers_reminder(transfer, current_event): + if transfer.max_days_ago % 30 in fibonacci_impares(5): + action = f"sending a notification to the recipient {transfer.tx_to_email} to remember to create an account, you have a pending ticket transfer since {transfer.max_days_ago} days ago. You have time until {current_event.transfers_enabled_until.strftime('%d/%m')}" + try: + if not MessageIdempotency.objects.filter(email=transfer.tx_to_email, hash=hash_string(action)).exists(): + send_mail( + template_name='recipient_pending_transfers_reminder', + recipient_list=[transfer.tx_to_email], + context={ + 'transfer': transfer, + 'current_event': current_event, + + } + ) + logging.info(action) + MessageIdempotency( + email=transfer.tx_to_email, + hash=hash_string(action), + payload=json.dumps( + { + 'action': 'send_recipient_pending_transfers_reminder', + 'transfer': transfer.to_dict(), + 'event_id': current_event.id + })).save() + return 1, 0 + except Exception as e: + logging.error(f"Error sending email to {transfer.tx_to_email}: {e}") + return 0, 0 + + +listita_emojis = [ + "❤️", "✨", "🔥", "🥺", "🌈", "🌟", "🎉", "😍", "💫", "🦋", + "🍀", "🌹", "🥳", "🐾", "🌺", "🐱", "🚀", "⚡️", "💖", "🎶", + "🌊", "💐", "🐶", "🌸", "🦄", "💥", "🍎", "🎂", "🎈", "🍕", + "📷", "🧩", "📚", "🎵", "🧁", "🍩", "🏆", "✈️", "🦊", "🍫", + "🎮", "🥂", "💎", "🏅", "🦉", "🕊️", "🏖️", "🕶️", "🍉", "🎤", + "📦", "🎥", "🍔", "🚗", "🥋", "🌵", "🦜", "🥥", "🥒", "🦀", + "🦓", "🦒", "🎸", "🍷", "📱", "🎻", "🏀", "🏈", "🚲", "🏔️", + "🛶", "🏊‍♂️", "🏄‍♀️", "🚤", "🚁", "🎯", "🛸", "🎳", "🎲", "🎱", + "🛷", "⛷️", "🧗‍♂️", "🎡", "🎢", "🎠", "🛹", "🛴", "🚎", "🚂", + "🚢", "🛰️", "🚀", "🏎️", "🛫", "🛬", "🚍", "🛳️", "🚤", "🚞", + "🛩️", "🏍️", "🛵", "🚲", "🚇", "🚉", "🚊", "🛤️", "🛣️", "🚥", + "🚦", "🚧", "⚓️", "⛵️", "🚤", "⛴️", "🛥️", "🛳️", "🚢", "✈️", + "🚀", "🛸", "💺", "🚁", "🚟", "🚠", "🚡", "🚜", "🏍️", "🛵", + "🛺", "🚘", "🚖", "🚑", "🚒", "🚓", "🚔", "🚨", "🚍", "🚲", + "🦽", "🦼", "🛴", "🛹", "🛷", "⛷️", "🏂", "🪂", "🏋️‍♂️", "🏋️‍♀️", + "🤼‍♂️", "🤼‍♀️", "🤸‍♂️", "🤸‍♀️", "⛹️‍♂️", "⛹️‍♀️", "🤾‍♂️", "🤾‍♀️", "🏌️‍♂️", "🏌️‍♀️", + "🏇", "🧘‍♂️", "🧘‍♀️", "🛀", "⛺️", "🏕️", "🏖️", "🏜️", "🏝️", "🏞️", + "🗻", "🏔️", "⛰️", "🏕️", "🏢", "🏬", "🏦", "🏥", "🏤", "🏣", + "🏛️", "🏟️", "🏡", "🏠", "🏚️", "🏢", "🏬", "🏭", "🏯", "🏰", + "🗽", "🗼", "🏛️", "🗾", "🎠", "🎡", "🎢", "💈", "🎪", "🎭", + "🖼️", "🎨", "🎰", "🚢", "🚚", "🚛", "🚜", "🚲", "🛴", "🛹", + "🛺", "🛵", "🏍️", "🚏", "🛤️", "🛣️", "🚇", "🚉", "🚊", "🚍" +] + + +def send_sender_pending_transfers_reminder(transfer, current_event): + emails_sent = 0 + messages_sent = 0 + if transfer.max_days_ago % 30 in fibonacci_impares(5): + try: + action = f"sending a notification to the sender {transfer.tx_from_email} to remember that tickets shared with {transfer.tx_to_emails}, were not accepted yet since {transfer.max_days_ago} days ago. Are you sure they are going to use them? Are the emails correct?. You have time until {current_event.transfers_enabled_until.strftime('%d/%m')}" + if not MessageIdempotency.objects.filter(email=transfer.tx_from_email, hash=hash_string(action)).exists(): + send_mail( + template_name='sender_pending_transfers_reminder', + recipient_list=[transfer.tx_from_email], + context={ + 'transfer': transfer, + 'current_event': current_event, + 'listita_emojis': listita_emojis + + } + ) + logging.info(action) + MessageIdempotency( + email=transfer.tx_from_email, + hash=hash_string(action), + payload=json.dumps( + { + 'action': 'send_sender_pending_transfers_reminder:email', + 'transfer': transfer.to_dict(), + 'event_id': current_event.id + })).save() + emails_sent = 1 + except Exception as e: + logging.error(f"Error sending email to {transfer.tx_from_email}: {e}") + + ## TODO Find a way to do this for free. Twillio charges periodically to first OWN a number. + ## We only send SMS notifications for transfers that are 2 days old to be assertive but not overwhelming + # if transfer.max_days_ago == 2: + # try: + # action = f"Hola, te escribe la matrix de Fuego Austral. Acordate que tenés {len(transfer.tx_to_emails)}, bonos sin transferir. Es para avisarte que no cuelges, tenes tiempo hasta el {current_event.transfers_enabled_until.strftime('%d/%m')}" + # if not MessageIdempotency.objects.filter(email=transfer.tx_from_email, + # hash=hash_string(transfer.tx_from_email)).exists(): + # logging.info(action) + # MessageIdempotency( + # email=transfer.tx_from_email, + # hash=hash_string(transfer.tx_from_email), + # # I hash like this cos I want to send only one SMS per sender. + # payload=json.dumps( + # { + # 'action': 'send_sender_pending_transfers_reminder:sms', + # 'transfer': transfer.to_dict(), + # 'event_id': current_event.id + # })).save() + # messages_sent = 1 + # except Exception as e: + # logging.error(f"Error sending SMS to {transfer.tx_from_email}: {e}") + + return emails_sent, messages_sent + + +def send_unsent_tickets_reminder_email(unsent_ticket, current_event): + if unsent_ticket.max_days_ago % 30 in fibonacci_impares(5): + try: + action = f"sending a notification to the holder {unsent_ticket.email} to remember to share the tickets, you have {unsent_ticket.pending_to_share_tickets} pending tickets since {unsent_ticket.max_days_ago} days ago. You have time until {current_event.transfers_enabled_until.strftime('%d/%m')}" + if not MessageIdempotency.objects.filter(email=unsent_ticket.email, hash=hash_string(action)).exists(): + send_mail( + template_name='unsent_tickets_reminder', + recipient_list=[unsent_ticket.email], + context={ + 'unsent_ticket': unsent_ticket, + 'current_event': current_event, + + } + ) + logging.info(action) + MessageIdempotency( + email=unsent_ticket.email, + hash=hash_string(action), + payload=json.dumps( + { + 'action': 'send_unsent_tickets_reminder_email', + 'unsent_ticket': unsent_ticket.to_dict(), + 'event_id': current_event.id + })).save() + return 1, 0 + except Exception as e: + logging.error(f"Error sending email to {unsent_ticket.email}: {e}") + return 0, 0 + + +class PendingTransferReceiver: + def __init__(self, tx_to_email, max_days_ago): + self.tx_to_email = tx_to_email + self.max_days_ago = max_days_ago + + def to_dict(self): + return { + 'tx_to_email': self.tx_to_email, + 'max_days_ago': int(self.max_days_ago) + } + + +def get_pending_transfers_recipients(event): + with connection.cursor() as cursor: + cursor.execute(""" + SELECT ntt.tx_to_email, MAX(EXTRACT(DAY FROM (NOW() - ntt.created_at))) as max_days_ago + FROM tickets_newtickettransfer ntt + INNER JOIN tickets_newticket nt ON nt.id = ntt.ticket_id + WHERE ntt.status = 'PENDING' and nt.event_id=%s + GROUP BY ntt.tx_to_email + """, [event.id]) + + return [PendingTransferReceiver(*row) for row in cursor.fetchall()] + + +class PendingTransferSender: + def __init__(self, tx_from_email, tx_to_emails, max_days_ago): + self.tx_from_email = tx_from_email + self.tx_to_emails = tx_to_emails + self.max_days_ago = max_days_ago + + def to_dict(self): + return { + 'tx_from_email': self.tx_from_email, + 'tx_to_emails': self.tx_to_emails, + 'max_days_ago': int(self.max_days_ago) + } + + +def get_pending_transfers_sender(event): + with connection.cursor() as cursor: + cursor.execute(""" + SELECT + u.email AS tx_from_email, + STRING_AGG(DISTINCT email_count.tx_to_email_with_count, ', ') AS tx_to_emails, + MAX(EXTRACT(DAY FROM (NOW() - ntt.created_at))) AS max_days_ago + FROM tickets_newtickettransfer ntt + INNER JOIN tickets_newticket nt ON nt.id = ntt.ticket_id + INNER JOIN auth_user u ON u.id = ntt.tx_from_id + INNER JOIN ( + SELECT + tx_to_email, + CONCAT(tx_to_email, ' (', COUNT(*)::text, ')') AS tx_to_email_with_count + FROM tickets_newtickettransfer + WHERE status = 'PENDING' + GROUP BY tx_to_email + ) AS email_count ON email_count.tx_to_email = ntt.tx_to_email + WHERE ntt.status = 'PENDING' AND nt.event_id = %s + GROUP BY u.email + """, [event.id]) + + # Create TransferDetails objects for each row fetched + results = cursor.fetchall() + + pending_transfers = [] + for row in results: + tx_from_email = row[0] + tx_to_emails_raw = row[1] + max_days_ago = row[2] + + # Convert tx_to_emails string to an array of dictionaries + tx_to_emails = [] + for tx_email_with_count in tx_to_emails_raw.split(', '): + email, count = tx_email_with_count.rsplit(' (', 1) + count = int(count.rstrip(')')) + tx_to_emails.append({'tx_to_email': email, 'pending_tickets': count}) + + pending_transfers.append(PendingTransferSender(tx_from_email, tx_to_emails, max_days_ago)) + + return pending_transfers + + +class PendingTicketHolder: + def __init__(self, email, pending_to_share_tickets, max_days_ago): + self.email = email + self.pending_to_share_tickets = pending_to_share_tickets + self.max_days_ago = max_days_ago + + def to_dict(self): + return { + 'email': self.email, + 'pending_to_share_tickets': int(self.pending_to_share_tickets), + 'max_days_ago': int(self.max_days_ago) + } + + +def get_unsent_tickets(current_event): + with connection.cursor() as cursor: + cursor.execute(""" + select email, + count(*) as pending_to_share_tickets, + max(EXTRACT(DAY FROM (NOW() - tickets_newticket.created_at))) as max_days_ago + + from tickets_newticket + + inner join auth_user on auth_user.id = tickets_newticket.holder_id + where owner_id is null + and event_id = %s + group by email + """, [current_event.id]) + + results = cursor.fetchall() + + pending_ticket_holders = [] + for row in results: + email = row[0] + pending_to_share_tickets = row[1] + max_days_ago = row[2] + + # Create an instance of PendingTicketHolder for each row + pending_ticket_holder = PendingTicketHolder(email, pending_to_share_tickets, max_days_ago) + pending_ticket_holders.append(pending_ticket_holder) + + return pending_ticket_holders + + +def fibonacci_impares(n, a=0, b=1, sequence=None): + if sequence is None: + sequence = [] + if len(sequence) >= n: + return sequence + if a % 2 != 0 and a not in sequence: + sequence.append(a) + return fibonacci_impares(n, b, a + b, sequence) + + +def hash_string(string_to_hash): + hash_object = hashlib.sha256() + + # Encode the string to bytes and update the hash object + hash_object.update(string_to_hash.encode('utf-8')) + + # Get the hexadecimal representation of the hash + return hash_object.hexdigest() diff --git a/tickets/forms.py b/tickets/forms.py index d509dc9..2040c67 100644 --- a/tickets/forms.py +++ b/tickets/forms.py @@ -1,26 +1,34 @@ from django import forms +from django.core.exceptions import ValidationError -from .models import Order, Ticket, TicketTransfer from events.models import Event +from .models import Order, Ticket, TicketTransfer, TicketType class PersonForm(forms.ModelForm): - first_name = forms.CharField(label='', widget=forms.TextInput(attrs={'class': 'input-first_name', 'placeholder': 'Nombre'})) - last_name = forms.CharField(label='', widget=forms.TextInput(attrs={'class': 'input-last_name', 'placeholder': 'Apellido'})) + first_name = forms.CharField(label='', + widget=forms.TextInput(attrs={'class': 'input-first_name', 'placeholder': 'Nombre'})) + last_name = forms.CharField(label='', + widget=forms.TextInput(attrs={'class': 'input-last_name', 'placeholder': 'Apellido'})) email = forms.EmailField(label='', widget=forms.EmailInput(attrs={'class': 'input-email', 'placeholder': 'Email'})) phone = forms.CharField(label='', widget=forms.TextInput(attrs={'class': 'input-phone', 'placeholder': 'Teléfono'})) - dni = forms.CharField(label='', widget=forms.TextInput(attrs={'class': 'input-dni', 'placeholder': 'DNI o Pasaporte'})) + dni = forms.CharField(label='', + widget=forms.TextInput(attrs={'class': 'input-dni', 'placeholder': 'DNI o Pasaporte'})) class TicketForm(PersonForm): - volunteer = forms.ChoiceField(label='Voluntariado', widget=forms.RadioSelect(attrs={'class': 'input-volunteer',}), choices=Ticket.VOLUNTEER_CHOICES) + volunteer = forms.ChoiceField(label='Voluntariado', widget=forms.RadioSelect(attrs={'class': 'input-volunteer', }), + choices=Ticket.VOLUNTEER_CHOICES) volunteer_ranger = forms.BooleanField(label='Ranger', required=False) volunteer_transmutator = forms.BooleanField(label='Transmutadores', required=False) volunteer_umpalumpa = forms.BooleanField(label='CAOS (Desarme de la Ciudad)', required=False) class Meta: model = Ticket - fields = ('first_name', 'last_name', 'email', 'phone', 'dni', 'volunteer', 'volunteer_ranger', 'volunteer_transmutator', 'volunteer_umpalumpa') + fields = ( + 'first_name', 'last_name', 'email', 'phone', 'dni', 'volunteer', 'volunteer_ranger', + 'volunteer_transmutator', + 'volunteer_umpalumpa') widgets = { 'volunteer': forms.RadioSelect } @@ -46,10 +54,180 @@ def clean(self): class OrderForm(PersonForm): class Meta: model = Order - fields = ('first_name', 'last_name', 'email', 'phone', 'dni', 'donation_art', 'donation_grant', 'donation_venue',) + fields = ( + 'first_name', 'last_name', 'email', 'phone', 'dni', 'donation_art', 'donation_grant', 'donation_venue',) class TransferForm(PersonForm): class Meta: model = TicketTransfer - fields = ('first_name', 'last_name', 'email', 'phone', 'dni', ) + fields = ('first_name', 'last_name', 'email', 'phone', 'dni',) + + +class CheckoutTicketSelectionForm(forms.Form): + def __init__(self, *args, **kwargs): + self.user = kwargs.pop('user', None) + initial_data = kwargs.pop('initial', {}) + super(CheckoutTicketSelectionForm, self).__init__(*args, **kwargs) + + ticket_types = TicketType.objects.get_available_ticket_types_for_current_events() + + self.ticket_data = [] # Initialize an empty list to store ticket data + + if ticket_types.exists(): + for ticket_type in ticket_types: + field_name = f'ticket_{ticket_type.id}_quantity' + initial_value = initial_data.get(field_name, 0) + + # Create a form field for each ticket type + self.fields[field_name] = forms.IntegerField( + label=f"{ticket_type.name}", + min_value=0, + max_value=ticket_type.ticket_count, + initial=initial_value + ) + + # Store ticket type and price to use in the template + self.ticket_data.append({ + 'id': ticket_type.id, + 'name': ticket_type.name, + 'description': ticket_type.description, + 'price': ticket_type.price, + 'field_name': field_name, + 'quantity': initial_value, # Pass the initial value to the template + 'ticket_count': ticket_type.ticket_count + }) + else: + self.ticket_data = [] + + def clean(self): + cleaned_data = super().clean() + + # check if any total selected tickets quantity is greater than available tickets + event = Event.objects.get(active=True) + tickets_remaining = event.tickets_remaining() or 0 + available_tickets = event.max_tickets_per_order + available_tickets = min(available_tickets, tickets_remaining) + total_selected_tickets = sum(cleaned_data.get(field, 0) for field in self.fields if field.startswith('ticket_')) + if total_selected_tickets > available_tickets: + # merge cleaned_data values with ticket_data quantity + self.ticket_data = [ + {**ticket, 'quantity': cleaned_data.get(f'ticket_{ticket["id"]}_quantity', ticket['quantity'])} + for ticket in self.ticket_data + ] + raise ValidationError(f'Superaste la máxima cantidad de bonos disponibles para esta compra: {available_tickets}.') + + # check if there's any ticket quantity greater than zero + if all(cleaned_data.get(field, 0) == 0 for field in self.fields if field.startswith('ticket_')): + raise ValidationError('Debe seleccionar al menos un ticket para continuar con la compra.') + + return cleaned_data + + +class CheckoutDonationsForm(forms.Form): + def __init__(self, *args, **kwargs): + initial_data = kwargs.pop('initial', {}) + super(CheckoutDonationsForm, self).__init__(*args, **kwargs) + self.fields['donation_art'] = forms.IntegerField( + label="Becas de Arte", + min_value=0, + initial=initial_data.get('donation_art', 0), + required=False + ) + self.fields['donation_venue'] = forms.IntegerField( + label="Donaciones a La Sede", + min_value=0, + initial=initial_data.get('donation_venue', 0), + required=False + ) + self.fields['donation_grant'] = forms.IntegerField( + label="Beca Inclusión Radical", + min_value=0, + initial=initial_data.get('donation_grant', 0), + required=False + ) + + +ORDER_REASON_CHOICES = [ + (Order.OrderType.CASH_ONSITE, 'Pago en efectivo'), + (Order.OrderType.INTERNATIONAL_TRANSFER, 'Transferencia internacional'), + (Order.OrderType.LOCAL_TRANSFER, 'Transferencia local'), + (Order.OrderType.OTHER, 'Otro'), + +] + +DOCUMENT_TYPE_CHOICES = [ + ('DNI', 'DNI'), + ('PASSPORT', 'Passport'), + ('OTHER', 'Other'), +] + + +class BaseTicketForm(forms.Form): + first_name = forms.CharField(label='Nombre', required=True) + last_name = forms.CharField(label='Apellido', required=True) + document_type = forms.ChoiceField(label='Tipo de documento', required=True, choices=DOCUMENT_TYPE_CHOICES) + document_number = forms.CharField(label='Número de documento', required=True, max_length=20) + phone = forms.CharField(label='Teléfono', required=True) + email = forms.EmailField(label='Email', required=True) + notes = forms.CharField(label='Notas', required=False, widget=forms.Textarea(attrs={'rows': 3})) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + # Add common widget attributes if needed + self.fields['first_name'].widget.attrs.update({'placeholder': 'Nombre'}) + self.fields['last_name'].widget.attrs.update({'placeholder': 'Apellido'}) + self.fields['email'].widget.attrs.update({'placeholder': 'Email'}) + self.fields['phone'].widget.attrs.update({'placeholder': 'Teléfono'}) + + def clean(self): + # Shared validation logic (if needed) + return super().clean() + + +class TicketPurchaseForm(BaseTicketForm): + order_type = forms.ChoiceField(label='Tipo de orden', choices=ORDER_REASON_CHOICES, required=True) + + def __init__(self, *args, **kwargs): + event = kwargs.pop('event', None) + super().__init__(*args, **kwargs) + + if event: + ticket_types = TicketType.objects.filter(event=event) + for ticket in ticket_types: + self.fields[f'ticket_quantity_{ticket.id}'] = forms.IntegerField( + label=f'Bonos tipo {ticket.emoji} {ticket.name} - ${ticket.price} - Quedan: {ticket.ticket_count}', + min_value=0, + max_value=ticket.ticket_count, + initial=0, + required=False, + ) + self.fields = { + 'order_type': self.fields['order_type'], + 'email': self.fields['email'], + 'first_name': self.fields['first_name'], + 'last_name': self.fields['last_name'], + 'document_type': self.fields['document_type'], + 'document_number': self.fields['document_number'], + 'phone': self.fields['phone'], + 'notes': self.fields['notes'], + **{field_name: self.fields[field_name] for field_name in self.fields if + field_name.startswith('ticket_quantity_')}, + } + + def clean(self): + cleaned_data = super().clean() + ticket_fields = [key for key in self.fields if key.startswith('ticket_quantity_')] + total_tickets = 0 + for field in ticket_fields: + ticket_id = field.split('_')[-1] + ticket = TicketType.objects.get(id=ticket_id) + ticket_quantity = cleaned_data.get(field, 0) + if ticket_quantity > ticket.ticket_count: + raise ValidationError(f'No hay suficientes tickets de tipo {ticket.name}.') + total_tickets += ticket_quantity + + if total_tickets == 0: + raise ValidationError('Debe seleccionar al menos un ticket para continuar con la compra.') + + return cleaned_data diff --git a/tickets/middleware.py b/tickets/middleware.py new file mode 100644 index 0000000..bc407c1 --- /dev/null +++ b/tickets/middleware.py @@ -0,0 +1,25 @@ +from django.shortcuts import redirect +from django.urls import reverse +from django.utils.deprecation import MiddlewareMixin + + +class ProfileCompletionMiddleware: + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + # Check if user is authenticated and not staff + if request.user.is_authenticated and not request.user.is_staff: + # Allow access to logout URL and complete_profile URL + if not request.path.startswith(reverse('account_logout')) and \ + not request.path.startswith(reverse('complete_profile')): + if request.user.profile.profile_completion != 'COMPLETE': + return redirect('complete_profile') + return self.get_response(request) + + +class DeviceDetectionMiddleware(MiddlewareMixin): + def process_request(self, request): + user_agent = request.META.get('HTTP_USER_AGENT', '').lower() + request.is_iphone = 'iphone' in user_agent + request.is_android = 'android' in user_agent diff --git a/tickets/migrations/0026_profile.py b/tickets/migrations/0026_profile.py new file mode 100644 index 0000000..b3516d7 --- /dev/null +++ b/tickets/migrations/0026_profile.py @@ -0,0 +1,31 @@ +# Generated by Django 4.2.15 on 2024-08-17 22:44 + +from django.conf import settings +import django.core.validators +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('tickets', '0025_add_first_ticket_type'), + ] + + operations = [ + migrations.CreateModel( + name='Profile', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('document_type', models.CharField(choices=[('DNI', 'DNI'), ('PASSPORT', 'Passport'), ('OTHER', 'Other')], default='DNI', max_length=10)), + ('document_number', models.CharField(max_length=50)), + ('phone', models.CharField(max_length=15, validators=[django.core.validators.RegexValidator('^\\+?1?\\d{9,15}$')])), + ('profile_completion', models.CharField(choices=[('NONE', 'None'), ('INITIAL_STEP', 'Initial Step'), ('COMPLETE', 'Complete')], default='NONE', max_length=15)), + ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + options={ + 'db_table': 'users_profile', + }, + ), + ] diff --git a/tickets/migrations/0027_alter_order_status.py b/tickets/migrations/0027_alter_order_status.py new file mode 100644 index 0000000..475bd48 --- /dev/null +++ b/tickets/migrations/0027_alter_order_status.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.15 on 2024-08-20 06:22 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('tickets', '0026_profile'), + ] + + operations = [ + migrations.AlterField( + model_name='order', + name='status', + field=models.CharField(choices=[('PENDING', 'Pendiente'), ('PROCESSING', 'Procesando'), ('CONFIRMED', 'Confirmada'), ('ERROR', 'Error'), ('REFUNDED', 'Reembolsada')], default='PENDING', max_length=20), + ), + ] diff --git a/tickets/migrations/0028_remove_order_ticket_type_orderticket.py b/tickets/migrations/0028_remove_order_ticket_type_orderticket.py new file mode 100644 index 0000000..fd4f203 --- /dev/null +++ b/tickets/migrations/0028_remove_order_ticket_type_orderticket.py @@ -0,0 +1,45 @@ +# Generated by Django 4.2.15 on 2024-08-21 00:08 + +from django.db import migrations, models +import django.db.models.deletion + +from django.db import migrations + + +def migrate_ticket_data(apps, schema_editor): + Order = apps.get_model('tickets', 'Order') + OrderTicket = apps.get_model('tickets', 'OrderTicket') + + # Iterate through all existing orders and create corresponding OrderTicket entries + for order in Order.objects.all(): + if order.ticket_type: + OrderTicket.objects.create( + order=order, + ticket_type=order.ticket_type, + quantity=1 # Assuming each order had one ticket type with quantity 1 + ) + + +class Migration(migrations.Migration): + dependencies = [ + ('tickets', '0027_alter_order_status'), + ] + + operations = [ + migrations.CreateModel( + name='OrderTicket', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('quantity', models.PositiveIntegerField(default=1)), + ('order', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='order_tickets', + to='tickets.order')), + ('ticket_type', + models.ForeignKey(on_delete=django.db.models.deletion.RESTRICT, to='tickets.tickettype')), + ], + ), + migrations.RunPython(migrate_ticket_data), + migrations.RemoveField( + model_name='order', + name='ticket_type', + ), + ] diff --git a/tickets/migrations/0029_alter_orderticket_ticket_type.py b/tickets/migrations/0029_alter_orderticket_ticket_type.py new file mode 100644 index 0000000..51a45a7 --- /dev/null +++ b/tickets/migrations/0029_alter_orderticket_ticket_type.py @@ -0,0 +1,19 @@ +# Generated by Django 4.2.15 on 2024-08-21 02:03 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('tickets', '0028_remove_order_ticket_type_orderticket'), + ] + + operations = [ + migrations.AlterField( + model_name='orderticket', + name='ticket_type', + field=models.ForeignKey(on_delete=django.db.models.deletion.RESTRICT, related_name='order_tickets', to='tickets.tickettype'), + ), + ] diff --git a/tickets/migrations/0030_newticket_order_event_newtickettransfer_and_more.py b/tickets/migrations/0030_newticket_order_event_newtickettransfer_and_more.py new file mode 100644 index 0000000..e58b2db --- /dev/null +++ b/tickets/migrations/0030_newticket_order_event_newtickettransfer_and_more.py @@ -0,0 +1,61 @@ +# Generated by Django 4.2.15 on 2024-08-22 03:22 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ('events', '0009_event_max_tickets_per_order'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('tickets', '0029_alter_orderticket_ticket_type'), + ] + + operations = [ + migrations.CreateModel( + name='NewTicket', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('key', models.UUIDField(default=uuid.uuid4, editable=False)), + ('volunteer_ranger', models.BooleanField(verbose_name='Rangers')), + ('volunteer_transmutator', models.BooleanField(verbose_name='Transmutadores')), + ('volunteer_umpalumpa', models.BooleanField(verbose_name='CAOS (Desarme de la Ciudad)')), + ('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='events.event')), + ('holder', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='held_tickets', to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.AddField( + model_name='order', + name='event', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.RESTRICT, to='events.event'), + ), + migrations.CreateModel( + name='NewTicketTransfer', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('tx_to_email', models.CharField(max_length=320)), + ('status', models.CharField(choices=[('PENDING', 'Pendiente'), ('CONFIRMED', 'Confirmado'), ('CANCELLED', 'Cancelado')], default='PENDING', max_length=10)), + ('ticket', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='tickets.newticket')), + ('tx_from', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='transferred_tickets', to=settings.AUTH_USER_MODEL)), + ('tx_to', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='received_tickets', to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.AddField( + model_name='newticket', + name='order', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='tickets.order'), + ), + migrations.AddField( + model_name='newticket', + name='owner', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='owned_tickets', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='newticket', + name='ticket_type', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='tickets.tickettype'), + ), + ] diff --git a/tickets/migrations/0031_order_user.py b/tickets/migrations/0031_order_user.py new file mode 100644 index 0000000..4eca61e --- /dev/null +++ b/tickets/migrations/0031_order_user.py @@ -0,0 +1,21 @@ +# Generated by Django 4.2.15 on 2024-08-22 03:30 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('tickets', '0030_newticket_order_event_newtickettransfer_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='order', + name='user', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.RESTRICT, to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/tickets/migrations/0032_alter_newticket_volunteer_ranger_and_more.py b/tickets/migrations/0032_alter_newticket_volunteer_ranger_and_more.py new file mode 100644 index 0000000..aa1921f --- /dev/null +++ b/tickets/migrations/0032_alter_newticket_volunteer_ranger_and_more.py @@ -0,0 +1,28 @@ +# Generated by Django 4.2.15 on 2024-08-22 03:32 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('tickets', '0031_order_user'), + ] + + operations = [ + migrations.AlterField( + model_name='newticket', + name='volunteer_ranger', + field=models.BooleanField(blank=True, null=True, verbose_name='Rangers'), + ), + migrations.AlterField( + model_name='newticket', + name='volunteer_transmutator', + field=models.BooleanField(blank=True, null=True, verbose_name='Transmutadores'), + ), + migrations.AlterField( + model_name='newticket', + name='volunteer_umpalumpa', + field=models.BooleanField(blank=True, null=True, verbose_name='CAOS (Desarme de la Ciudad)'), + ), + ] diff --git a/tickets/migrations/0033_newticket_created_at_newticket_updated_at_and_more.py b/tickets/migrations/0033_newticket_created_at_newticket_updated_at_and_more.py new file mode 100644 index 0000000..898a40b --- /dev/null +++ b/tickets/migrations/0033_newticket_created_at_newticket_updated_at_and_more.py @@ -0,0 +1,36 @@ +# Generated by Django 4.2.15 on 2024-08-27 00:40 + +from django.db import migrations, models +import django.utils.timezone + + +class Migration(migrations.Migration): + + dependencies = [ + ('tickets', '0032_alter_newticket_volunteer_ranger_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='newticket', + name='created_at', + field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now), + preserve_default=False, + ), + migrations.AddField( + model_name='newticket', + name='updated_at', + field=models.DateTimeField(auto_now=True), + ), + migrations.AddField( + model_name='newtickettransfer', + name='created_at', + field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now), + preserve_default=False, + ), + migrations.AddField( + model_name='newtickettransfer', + name='updated_at', + field=models.DateTimeField(auto_now=True), + ), + ] diff --git a/tickets/migrations/0034_messageidempotency.py b/tickets/migrations/0034_messageidempotency.py new file mode 100644 index 0000000..aa560f8 --- /dev/null +++ b/tickets/migrations/0034_messageidempotency.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2.15 on 2024-08-29 00:00 + +from django.db import migrations, models +import jsonfield.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('tickets', '0033_newticket_created_at_newticket_updated_at_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='MessageIdempotency', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('email', models.EmailField(max_length=254)), + ('hash', models.CharField(max_length=64, unique=True)), + ('payload', jsonfield.fields.JSONField()), + ], + ), + ] diff --git a/tickets/migrations/0035_order_order_type.py b/tickets/migrations/0035_order_order_type.py new file mode 100644 index 0000000..c54a4b4 --- /dev/null +++ b/tickets/migrations/0035_order_order_type.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.15 on 2024-08-31 20:38 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('tickets', '0034_messageidempotency'), + ] + + operations = [ + migrations.AddField( + model_name='order', + name='order_type', + field=models.CharField(choices=[('ONLINE_PURCHASE', 'Compra Online'), ('CASH_ONSITE', 'Efectivo'), ('CAMPS', 'Camps'), ('ART', 'Arte'), ('OTHER', 'Otro')], default='ONLINE_PURCHASE', max_length=20), + ), + ] diff --git a/tickets/migrations/0036_order_notes.py b/tickets/migrations/0036_order_notes.py new file mode 100644 index 0000000..c007eaa --- /dev/null +++ b/tickets/migrations/0036_order_notes.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.15 on 2024-10-20 19:38 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('tickets', '0035_order_order_type'), + ] + + operations = [ + migrations.AddField( + model_name='order', + name='notes', + field=models.TextField(blank=True, null=True), + ), + ] diff --git a/tickets/migrations/0037_alter_order_order_type.py b/tickets/migrations/0037_alter_order_order_type.py new file mode 100644 index 0000000..b4efb91 --- /dev/null +++ b/tickets/migrations/0037_alter_order_order_type.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.15 on 2024-10-21 02:59 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('tickets', '0036_order_notes'), + ] + + operations = [ + migrations.AlterField( + model_name='order', + name='order_type', + field=models.CharField(choices=[('INTERNATIONAL_TRANSFER', 'Transferencia Internacional'), ('LOCAL_TRANSFER', 'Transferencia Local'), ('ONLINE_PURCHASE', 'Compra Online'), ('CASH_ONSITE', 'Efectivo'), ('OTHER', 'Otro')], default='ONLINE_PURCHASE', max_length=32), + ), + ] diff --git a/tickets/migrations/0038_directtickettemplate.py b/tickets/migrations/0038_directtickettemplate.py new file mode 100644 index 0000000..63df20c --- /dev/null +++ b/tickets/migrations/0038_directtickettemplate.py @@ -0,0 +1,25 @@ +# Generated by Django 4.2.15 on 2024-10-20 22:19 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('events', '0009_event_max_tickets_per_order'), + ('tickets', '0036_order_notes'), + ] + + operations = [ + migrations.CreateModel( + name='DirectTicketTemplate', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('origin', models.CharField(choices=[('CAMP', 'Camp'), ('VOLUNTARIOS', 'Voluntarios'), ('ARTE', 'Arte')], default='CAMP', max_length=20)), + ('name', models.CharField(help_text='Descripción o referencia', max_length=255)), + ('amount', models.PositiveIntegerField()), + ('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='events.event')), + ], + ), + ] diff --git a/tickets/migrations/0038_order_generated_by.py b/tickets/migrations/0038_order_generated_by.py new file mode 100644 index 0000000..0187915 --- /dev/null +++ b/tickets/migrations/0038_order_generated_by.py @@ -0,0 +1,21 @@ +# Generated by Django 4.2.15 on 2024-10-22 16:10 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('tickets', '0037_alter_order_order_type'), + ] + + operations = [ + migrations.AddField( + model_name='order', + name='generated_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.RESTRICT, related_name='generated_by', to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/tickets/migrations/0039_alter_directtickettemplate_options_and_more.py b/tickets/migrations/0039_alter_directtickettemplate_options_and_more.py new file mode 100644 index 0000000..e7015ee --- /dev/null +++ b/tickets/migrations/0039_alter_directtickettemplate_options_and_more.py @@ -0,0 +1,26 @@ +# Generated by Django 4.2.15 on 2024-10-21 02:21 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ('tickets', '0038_directtickettemplate'), + ] + + operations = [ + migrations.AlterModelOptions( + name='directtickettemplate', + options={'verbose_name': 'Bono dirigido', 'verbose_name_plural': 'Config Bonos dirigidos'}, + ), + migrations.AddField( + model_name='directtickettemplate', + name='used', + field=models.BooleanField(default=False), + ), + migrations.AlterField( + model_name='directtickettemplate', + name='name', + field=models.CharField(help_text='Descripción y/o referencias', max_length=255), + ), + ] diff --git a/tickets/migrations/0039_delete_profile.py b/tickets/migrations/0039_delete_profile.py new file mode 100644 index 0000000..1b320f5 --- /dev/null +++ b/tickets/migrations/0039_delete_profile.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2.15 on 2024-10-27 18:37 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("tickets", "0038_order_generated_by"), + ] + database_operations = [migrations.AlterModelTable("Profile", "user_profile_profile")] + + state_operations = [ + migrations.DeleteModel( + name="Profile", + ), + ] + + operations = [ + migrations.SeparateDatabaseAndState( + database_operations=database_operations, state_operations=state_operations + ) + ] diff --git a/tickets/migrations/0040_alter_newticket_options_and_more.py b/tickets/migrations/0040_alter_newticket_options_and_more.py new file mode 100644 index 0000000..cfb1235 --- /dev/null +++ b/tickets/migrations/0040_alter_newticket_options_and_more.py @@ -0,0 +1,21 @@ +# Generated by Django 4.2.15 on 2024-10-28 12:30 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('tickets', '0039_delete_profile'), + ] + + operations = [ + migrations.AlterModelOptions( + name='newticket', + options={'verbose_name': 'Ticket'}, + ), + migrations.AlterModelOptions( + name='newtickettransfer', + options={'verbose_name': 'Ticket transfer'}, + ), + ] diff --git a/tickets/migrations/0040_merge_20241022_0846.py b/tickets/migrations/0040_merge_20241022_0846.py new file mode 100644 index 0000000..31576a0 --- /dev/null +++ b/tickets/migrations/0040_merge_20241022_0846.py @@ -0,0 +1,14 @@ +# Generated by Django 4.2.15 on 2024-10-22 11:46 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('tickets', '0037_alter_order_order_type'), + ('tickets', '0039_alter_directtickettemplate_options_and_more'), + ] + + operations = [ + ] diff --git a/tickets/migrations/0041_tickettype_is_direct_type.py b/tickets/migrations/0041_tickettype_is_direct_type.py new file mode 100644 index 0000000..e7b6a20 --- /dev/null +++ b/tickets/migrations/0041_tickettype_is_direct_type.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.15 on 2024-10-22 11:47 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('tickets', '0040_merge_20241022_0846'), + ] + + operations = [ + migrations.AddField( + model_name='tickettype', + name='is_direct_type', + field=models.BooleanField(default=False), + ), + ] diff --git a/tickets/migrations/0042_directtickettemplate_status.py b/tickets/migrations/0042_directtickettemplate_status.py new file mode 100644 index 0000000..94fc91d --- /dev/null +++ b/tickets/migrations/0042_directtickettemplate_status.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.15 on 2024-10-22 12:23 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('tickets', '0041_tickettype_is_direct_type'), + ] + + operations = [ + migrations.AddField( + model_name='directtickettemplate', + name='status', + field=models.CharField(choices=[('AVAILABLE', 'Disponible'), ('PENDING', 'Pendiente'), ('ASSIGNED', 'Asignados')], default='AVAILABLE', max_length=20), + ), + ] diff --git a/tickets/migrations/0043_merge_20241027_1540.py b/tickets/migrations/0043_merge_20241027_1540.py new file mode 100644 index 0000000..810a407 --- /dev/null +++ b/tickets/migrations/0043_merge_20241027_1540.py @@ -0,0 +1,14 @@ +# Generated by Django 4.2.15 on 2024-10-27 18:40 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('tickets', '0038_order_generated_by'), + ('tickets', '0042_directtickettemplate_status'), + ] + + operations = [ + ] diff --git a/tickets/migrations/0044_alter_newticket_holder.py b/tickets/migrations/0044_alter_newticket_holder.py new file mode 100644 index 0000000..331e051 --- /dev/null +++ b/tickets/migrations/0044_alter_newticket_holder.py @@ -0,0 +1,21 @@ +# Generated by Django 4.2.15 on 2024-10-28 02:39 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('tickets', '0043_merge_20241027_1540'), + ] + + operations = [ + migrations.AlterField( + model_name='newticket', + name='holder', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='held_tickets', to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/tickets/migrations/0045_merge_20241028_2246.py b/tickets/migrations/0045_merge_20241028_2246.py new file mode 100644 index 0000000..eebf0b4 --- /dev/null +++ b/tickets/migrations/0045_merge_20241028_2246.py @@ -0,0 +1,14 @@ +# Generated by Django 4.2.15 on 2024-10-29 01:46 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('tickets', '0040_alter_newticket_options_and_more'), + ('tickets', '0044_alter_newticket_holder'), + ] + + operations = [ + ] diff --git a/tickets/migrations/0046_remove_directtickettemplate_used.py b/tickets/migrations/0046_remove_directtickettemplate_used.py new file mode 100644 index 0000000..62fbf31 --- /dev/null +++ b/tickets/migrations/0046_remove_directtickettemplate_used.py @@ -0,0 +1,17 @@ +# Generated by Django 4.2.15 on 2024-10-29 03:05 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('tickets', '0045_merge_20241028_2246'), + ] + + operations = [ + migrations.RemoveField( + model_name='directtickettemplate', + name='used', + ), + ] diff --git a/tickets/migrations/0047_alter_directtickettemplate_options_and_more.py b/tickets/migrations/0047_alter_directtickettemplate_options_and_more.py new file mode 100644 index 0000000..54abfc0 --- /dev/null +++ b/tickets/migrations/0047_alter_directtickettemplate_options_and_more.py @@ -0,0 +1,21 @@ +# Generated by Django 4.2.15 on 2024-11-03 22:31 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('tickets', '0046_remove_directtickettemplate_used'), + ] + + operations = [ + migrations.AlterModelOptions( + name='directtickettemplate', + options={'permissions': [('admin_volunteers', 'Can admin Volunteers')], 'verbose_name': 'Bono dirigido', 'verbose_name_plural': 'Config Bonos dirigidos'}, + ), + migrations.AlterModelOptions( + name='order', + options={'permissions': [('can_sell_tickets', 'Can sell tickets in Caja')]}, + ), + ] diff --git a/tickets/models.py b/tickets/models.py index 315d10c..06d6248 100644 --- a/tickets/models.py +++ b/tickets/models.py @@ -1,21 +1,25 @@ +import base64 +import logging +import uuid from datetime import datetime from decimal import Decimal -import uuid -import qrcode -import logging +from io import BytesIO +import jsonfield +import qrcode +from auditlog.registry import auditlog +from django.conf import settings +from django.contrib.auth.models import User from django.core.validators import MinValueValidator -from django.db import models +from django.db import models, transaction from django.db.models import Count, Sum, Q, F -from django.conf import settings from django.urls import reverse - -from auditlog.registry import auditlog -from templated_email import InlineImage +from django.utils import timezone from events.models import Event from utils.email import send_mail from utils.models import BaseModel +from .processing import mint_tickets class Coupon(BaseModel): @@ -28,11 +32,11 @@ def __str__(self): def tickets_remaining(self): tickets_sold = (Order.objects - .filter(coupon=self) - .filter(status=Order.OrderStatus.CONFIRMED) - .annotate(num_tickets=Count('ticket')) - .aggregate(tickets_sold=Sum('num_tickets') - ))['tickets_sold'] or 0 + .filter(coupon=self) + .filter(status=Order.OrderStatus.CONFIRMED) + .annotate(num_tickets=Count('ticket')) + .aggregate(tickets_sold=Sum('num_tickets') + ))['tickets_sold'] or 0 try: return max(0, self.max_tickets - (tickets_sold or 0)) @@ -41,40 +45,46 @@ def tickets_remaining(self): class TicketTypeManager(models.Manager): + # get all available ticket types for available events + def get_available_ticket_types_for_current_events(self): + return (self + .filter(event__active=True) + .filter(Q(date_from__lte=timezone.now()) | Q(date_from__isnull=True)) + .filter(Q(date_to__gte=timezone.now()) | Q(date_to__isnull=True)) + .filter(Q(ticket_count__gt=0) | Q(ticket_count__isnull=True)) + .filter(is_direct_type=False) + ) + def get_available(self, coupon, event): if event.tickets_remaining() <= 0: return TicketType.objects.none() - ticket_types = (TicketType.objects - .filter(event=event) - - # filter by date - .filter(Q(date_from__lte=datetime.now()) | Q(date_from__isnull=True)) - .filter(Q(date_to__gte=datetime.now()) | Q(date_to__isnull=True)) + # Get the current time using Django's timezone utility + now = timezone.now() - # filter by sold out tickets - .annotate(confirmed_tickets=Count('order__ticket', filter=Q(order__status=Order.OrderStatus.CONFIRMED))) - .annotate(available_tickets=F('ticket_count') - F('confirmed_tickets')) - .filter(available_tickets__gt=0) + # Query available ticket types for the event + ticket_types = TicketType.objects.filter(event=event).filter( + Q(date_from__lte=timezone.now()) | Q(date_from__isnull=True), + Q(date_to__gte=timezone.now()) | Q(date_to__isnull=True), + Q(ticket_count__gt=0) | Q(ticket_count__isnull=True) ) - # add coupon filter + # Apply coupon filtering if a coupon is provided if coupon: - # block purchase if the allowed tickets for a coupon have been sold if coupon.tickets_remaining() <= 0: return TicketType.objects.none() ticket_types = ticket_types.filter(coupon=coupon) else: - # filter the ones only for coupons + # Exclude ticket types that are only available with coupons ticket_types = ticket_types.filter(price__isnull=False, price__gt=0) - # just get the cheapest one available + # If event does not show multiple tickets, return the cheapest available ticket if not event.show_multiple_tickets: - try: - first_ticket = ticket_types.order_by('price_with_coupon' if coupon else 'price').first() + first_ticket = ticket_types.order_by('price_with_coupon' if coupon else 'price').first() + if first_ticket: return ticket_types.filter(id=first_ticket.id) - except IndexError: + else: return TicketType.objects.none() return ticket_types @@ -89,22 +99,12 @@ class TicketType(BaseModel): name = models.CharField(max_length=100) description = models.TextField(max_length=2000, blank=True) color = models.CharField(max_length=6, default='6633ff') - emoji=models.CharField(max_length=255, default='🖕') - ticket_count=models.IntegerField() + emoji = models.CharField(max_length=255, default='🖕') + ticket_count = models.IntegerField() objects = TicketTypeManager() - # class Meta: - # constraints = [ - # models.CheckConstraint( - # name="%(app_label)s_%(class)s_price_or_price_with_coupon", - # check=( - # models.Q(price__isnull=True, price_with_coupon__isnull=False) - # | models.Q(price__isnull=False, price_with_coupon__isnull=True) - # | models.Q(price__isnull=True, price_with_coupon__isnull=True) - # ), - # ) - # ] + is_direct_type = models.BooleanField(default=False) def get_corresponding_ticket_type(coupon: Coupon): return TicketType.objects \ @@ -120,33 +120,78 @@ def __str__(self): return f"{self.name} ({self.event.name})" +class OrderTicket(models.Model): + order = models.ForeignKey('Order', related_name='order_tickets', on_delete=models.CASCADE) + ticket_type = models.ForeignKey('TicketType', related_name='order_tickets', on_delete=models.RESTRICT) + quantity = models.PositiveIntegerField(default=1) + + class Order(BaseModel): + class OrderStatus(models.TextChoices): + PENDING = 'PENDING', 'Pendiente' + PROCESSING = 'PROCESSING', 'Procesando' + CONFIRMED = 'CONFIRMED', 'Confirmada' + ERROR = 'ERROR', 'Error' + REFUNDED = 'REFUNDED', 'Reembolsada' + + class OrderType(models.TextChoices): + INTERNATIONAL_TRANSFER = 'INTERNATIONAL_TRANSFER', 'Transferencia Internacional' + LOCAL_TRANSFER = 'LOCAL_TRANSFER', 'Transferencia Local' + ONLINE_PURCHASE = 'ONLINE_PURCHASE', 'Compra Online' + CASH_ONSITE = 'CASH_ONSITE', 'Efectivo' + OTHER = 'OTHER', 'Otro' + key = models.UUIDField(default=uuid.uuid4, editable=False) + first_name = models.CharField(max_length=255) last_name = models.CharField(max_length=255) email = models.CharField(max_length=320) phone = models.CharField(max_length=50) dni = models.CharField(max_length=10) - donation_art = models.DecimalField('Becas de Arte $', validators=[MinValueValidator(Decimal('0'))], decimal_places=0, max_digits=10, blank=True, null=True, help_text='Para empujar la creatividad en nuestra ciudad temporal.') - donation_venue = models.DecimalField('Donaciones a La Sede $', validators=[MinValueValidator(Decimal('0'))], decimal_places=0, max_digits=10, blank=True, null=True, help_text='Para mejorar el espacio donde nos encontramos todo el año.') - donation_grant = models.DecimalField('Beca Inclusión Radical $', validators=[MinValueValidator(Decimal('0'))], decimal_places=0, max_digits=10, blank=True, null=True, help_text='Para ayudar a quienes necesitan una mano con su bono contribución.') + donation_art = models.DecimalField('Becas de Arte $', validators=[MinValueValidator(Decimal('0'))], + decimal_places=0, max_digits=10, blank=True, null=True, + help_text='Para empujar la creatividad en nuestra ciudad temporal.') + donation_venue = models.DecimalField('Donaciones a La Sede $', validators=[MinValueValidator(Decimal('0'))], + decimal_places=0, max_digits=10, blank=True, null=True, + help_text='Para mejorar el espacio donde nos encontramos todo el año.') + donation_grant = models.DecimalField('Beca Inclusión Radical $', validators=[MinValueValidator(Decimal('0'))], + decimal_places=0, max_digits=10, blank=True, null=True, + help_text='Para ayudar a quienes necesitan una mano con su bono contribución.') amount = models.DecimalField(decimal_places=2, max_digits=10) coupon = models.ForeignKey('Coupon', null=True, blank=True, on_delete=models.RESTRICT) - ticket_type = models.ForeignKey('TicketType', on_delete=models.RESTRICT) response = models.JSONField(null=True, blank=True) + event = models.ForeignKey(Event, null=True, blank=True, on_delete=models.RESTRICT) + user = models.ForeignKey(User, null=True, blank=True, on_delete=models.RESTRICT) - class OrderStatus(models.TextChoices): - PENDING = 'PENDING', 'Pendiente' - CONFIRMED = 'CONFIRMED', 'Confirmada' - ERROR = 'ERROR', 'Error' status = models.CharField( max_length=20, choices=OrderStatus.choices, default=OrderStatus.PENDING ) + order_type = models.CharField( + max_length=32, + choices=OrderType.choices, + default=OrderType.ONLINE_PURCHASE + ) + + notes = models.TextField(null=True, blank=True) + generated_by = models.ForeignKey(User, related_name='generated_by', null=True, blank=True, + on_delete=models.RESTRICT) + + class Meta: + permissions = [ + ("can_sell_tickets", "Can sell tickets in Caja"), + ] + + def total_ticket_types(self): + return self.order_tickets.count() + + def total_order_tickets(self): + return self.order_tickets.aggregate(total=Sum('quantity'))['total'] + def get_resource_url(self): return reverse('order_detail', kwargs={'order_key': self.key}) @@ -154,29 +199,24 @@ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self._old_status = self.status - def save(self): - super(Order, self).save() - logging.info(f'Order {self.id} saved with status {self.status}') - logging.info(f'Old status was {self._old_status}') - if self._old_status != Order.OrderStatus.CONFIRMED and self.status == Order.OrderStatus.CONFIRMED: - logging.info(f'Order {self.id} confirmed') - self.send_confirmation_email() - logging.info(f'Order {self.id} confirmation email sent') - for ticket in self.ticket_set.all(): - logging.info(f'Order {self.id} ticket {ticket.id} created') - ticket.send_email() - logging.info(f'Order {self.id} ticket {ticket.id} email sent') + def save(self, *args, **kwargs): + super().save(*args, **kwargs) + if self.status == Order.OrderStatus.PROCESSING and self._old_status != self.status: + self._old_status = self.status + mint_tickets(self) def send_confirmation_email(self): + send_mail( template_name='order_success', recipient_list=[self.email], context={ 'order': self, - 'url': self.get_resource_url(), - 'event': self.ticket_type.event, + 'event': self.event, + 'has_many_tickets': NewTicket.objects.filter(holder=self.user, event=self.event).count() > 1, } ) + logging.info(f'Order {self.id} confirmation email sent') def get_payment_preference(self): @@ -185,10 +225,10 @@ def get_payment_preference(self): items = [] items.extend([{ - "title": self.ticket_type.name, - "quantity": 1, - "unit_price": ticket.price, - } for ticket in self.ticket_set.all() if ticket.price >0]) + "title": self.ticket_type.name, + "quantity": 1, + "unit_price": ticket.price, + } for ticket in self.ticket_set.all() if ticket.price > 0]) if self.donation_art: items.append({ @@ -217,7 +257,7 @@ def get_payment_preference(self): "name": self.first_name, "surname": self.last_name, "email": self.email, - "identification": { "type": "DNI", "number": self.dni }, + "identification": {"type": "DNI", "number": self.dni}, }, "back_urls": { "success": settings.APP_URL + reverse("payment_success_callback", kwargs={'order_key': self.key}), @@ -235,7 +275,86 @@ def get_payment_preference(self): return response def __str__(self): - return f'#{self.pk} {self.last_name}' + return f'Order #{self.pk} {self.last_name} - {self.email} - {self.status} - {self.amount}' + + +class NewTicket(BaseModel): + key = models.UUIDField(default=uuid.uuid4, editable=False) + event = models.ForeignKey(Event, on_delete=models.CASCADE) + order = models.ForeignKey('Order', on_delete=models.CASCADE) + ticket_type = models.ForeignKey('TicketType', on_delete=models.CASCADE) + owner = models.ForeignKey(User, related_name='owned_tickets', null=True, blank=True, on_delete=models.SET_NULL) + holder = models.ForeignKey(User, related_name='held_tickets', null=True, blank=True, on_delete=models.CASCADE) + + volunteer_ranger = models.BooleanField('Rangers', null=True, blank=True, ) + volunteer_transmutator = models.BooleanField('Transmutadores', null=True, blank=True, ) + volunteer_umpalumpa = models.BooleanField('CAOS (Desarme de la Ciudad)', null=True, blank=True, ) + + def generate_qr_code(self): + # Generate the QR code + img = qrcode.make(f'{self.key}') + img_io = BytesIO() + img.save(img_io, format='PNG') + img_io.seek(0) + + # Encode the image to base64 + img_data_base64 = base64.b64encode(img_io.getvalue()).decode('utf-8') + + return img_data_base64 + + def save(self): + with transaction.atomic(): + is_new = self.pk is None + super(NewTicket, self).save() + + if is_new: + ticket_type = TicketType.objects.get(id=self.ticket_type.id) + ticket_type.ticket_count = ticket_type.ticket_count - 1 + ticket_type.save() + + def get_dto(self, user): + transfer_pending = NewTicketTransfer.objects.filter(ticket=self, tx_from=user, + status='PENDING').first() + return { + 'key': self.key, + 'order': self.order.key, + 'ticket_type': self.ticket_type.name, + 'ticket_color': self.ticket_type.color, + 'emoji': self.ticket_type.emoji, + 'price': self.ticket_type.price, + 'is_transfer_pending': transfer_pending is not None, + 'transferring_to': transfer_pending.tx_to_email if transfer_pending else None, + 'is_owners': self.holder == self.owner, + 'volunteer_ranger': self.volunteer_ranger, + 'volunteer_transmutator': self.volunteer_transmutator, + 'volunteer_umpalumpa': self.volunteer_umpalumpa, + 'qr_code': self.generate_qr_code(), + } + + def is_volunteer(self): + return self.volunteer_ranger or self.volunteer_transmutator or self.volunteer_umpalumpa + + def __str__(self): + return f'Ticket {self.key} - {self.ticket_type} - holder: {self.holder} - owner: {self.owner}' + + class Meta: + verbose_name = 'Ticket' + + +class NewTicketTransfer(BaseModel): + ticket = models.ForeignKey(NewTicket, on_delete=models.CASCADE) + tx_from = models.ForeignKey(User, related_name='transferred_tickets', null=True, blank=True, + on_delete=models.CASCADE) + tx_to = models.ForeignKey(User, related_name='received_tickets', null=True, blank=True, on_delete=models.CASCADE) + tx_to_email = models.CharField(max_length=320) + TRANSFER_STATUS = (('PENDING', 'Pendiente'), ('CONFIRMED', 'Confirmado'), ('CANCELLED', 'Cancelado')) + status = models.CharField(max_length=10, choices=TRANSFER_STATUS, default='PENDING') + + def __str__(self): + return f'Transaction from {self.tx_from.email} to {self.tx_to_email} - Ticket {self.ticket.key} - {self.status} - Ceated {(timezone.now() - self.created_at).days} days ago' + + class Meta: + verbose_name = 'Ticket transfer' class TicketPerson(models.Model): @@ -272,9 +391,7 @@ def __str__(self): def get_absolute_url(self): return reverse('ticket_detail', args=(self.key,)) - def send_email(self): - # img = qrcode.make(f'{settings.APP_URL}{url}') # logo = Image.open('tickets/static/img/logo.png') @@ -318,7 +435,6 @@ def transfer(self): self.save() def send_email(self): - return send_mail( template_name='transfer', recipient_list=[self.ticket.email], @@ -332,7 +448,55 @@ def get_absolute_url(self): return reverse('ticket_transfer_confirmed', args=(self.key,)) +class MessageIdempotency(models.Model): + email = models.EmailField() + hash = models.CharField(max_length=64, unique=True) + payload = jsonfield.JSONField() + + def __str__(self): + return f"{self.email} - {self.hash}" + + +class DirectTicketTemplateOriginChoices(models.TextChoices): + CAMP = 'CAMP', 'Camp' + VOLUNTEER = 'VOLUNTARIOS', 'Voluntarios' + ART = 'ARTE', 'Arte' + + +class DirectTicketTemplateStatus(models.TextChoices): + AVAILABLE = 'AVAILABLE', 'Disponible' + PENDING = 'PENDING', 'Pendiente' + ASSIGNED = 'ASSIGNED', 'Asignados' + + +class DirectTicketTemplate(models.Model): + origin = models.CharField( + max_length=20, + choices=DirectTicketTemplateOriginChoices.choices, + default=DirectTicketTemplateOriginChoices.CAMP, + ) + name = models.CharField(max_length=255, help_text="Descripción y/o referencias") + amount = models.PositiveIntegerField() + event = models.ForeignKey(Event, on_delete=models.CASCADE) + status = models.CharField(max_length=20, choices=DirectTicketTemplateStatus.choices, + default=DirectTicketTemplateStatus.AVAILABLE) + + class Meta: + verbose_name = "Bono dirigido" + verbose_name_plural = "Config Bonos dirigidos" + permissions = [ + ("admin_volunteers", "Can admin Volunteers"), + ] + + def __str__(self): + return f"{self.name} ({self.origin}) - {self.amount}" + + auditlog.register(Coupon) auditlog.register(TicketType) auditlog.register(Order) auditlog.register(Ticket) +auditlog.register(NewTicket) +auditlog.register(NewTicketTransfer) +auditlog.register(TicketTransfer) +auditlog.register(DirectTicketTemplate) diff --git a/tickets/processing.py b/tickets/processing.py new file mode 100644 index 0000000..50a5382 --- /dev/null +++ b/tickets/processing.py @@ -0,0 +1,49 @@ +import logging +from django.db import transaction + + +def mint_tickets(order): + + try: + from tickets.models import NewTicket, OrderTicket, Order + user_already_has_ticket = NewTicket.objects.filter(owner=order.user).exists() + logging.info(f"user_already_has_ticket {user_already_has_ticket}") + order_has_more_than_one_ticket_type = order.total_ticket_types() > 1 + logging.info(f"order_has_more_than_one_ticket_type {order_has_more_than_one_ticket_type}") + + order_tickets = OrderTicket.objects.filter(order=order) + + new_minted_tickets = [] + with transaction.atomic(): + for ticket in order_tickets: + for _ in range(ticket.quantity): + new_ticket = NewTicket( + holder=order.user, + ticket_type=ticket.ticket_type, + order=order, + event=order.event, + ) + + if not user_already_has_ticket and not order_has_more_than_one_ticket_type: + new_ticket.owner = order.user + user_already_has_ticket = True + + new_ticket.save() + new_minted_tickets.append(new_ticket) + + order.status = Order.OrderStatus.CONFIRMED + order.save() + + for ticket in new_minted_tickets: + logging.info(f"Minted {ticket}") + + + order.send_confirmation_email() + + except AttributeError as e: + logging.error(f"Attribute error in minting tickets: {str(e)}") + raise e + + except Exception as e: + logging.error(f"Error minting tickets: {str(e)}") + raise e diff --git a/tickets/static/css/barbu-style.css b/tickets/static/css/barbu-style.css new file mode 100644 index 0000000..199dec3 --- /dev/null +++ b/tickets/static/css/barbu-style.css @@ -0,0 +1,602 @@ +:root { + --text-color: #1e1e1e; + --bs-emphasis-color: var(--text-color); + --bs-border-radius: 0.5rem; + --bs-border-color: #b2b2b2; + --primary-color: #289e6d; + --primary-color-lighter: #33b37d; +} +.btn-primary { + --bs-btn-bg: var(--primary-color); + --bs-btn-border-color: var(--primary-color); + --bs-btn-hover-bg: var(--primary-color-lighter); + --bs-btn-hover-border-color: var(--primary-color-lighter); + --bs-btn-disabled-bg: var(--primary-color-lighter); + --bs-btn-disabled-border-color: var(--primary-color-lighter); + --bs-btn-active-border-color: var(--primary-color-lighter); + --bs-btn-active-bg: var(--primary-color-lighter); +} +.btn-secondary { + --bs-btn-border-color: #d9d9d9; + --bs-btn-hover-bg: #eee; + --bs-btn-bg: #fff; + background-color: var(--bs-btn-bg); +} + +body { + font-family: "Inter", sans-serif; + color: var(--text-color); +} + +input { + color: var(--text-color); +} + +img { + max-width: 100%; + object-fit: cover; +} + +header, +footer { + background: #1d0402; + padding: 40px 80px; +} +header.container, +footer.container { + padding: 40px 80px; +} + +.logo { + color: #fff !important; + font-size: 1.25rem; + font-weight: 600; + text-decoration: none; +} + +.nav-item { + height: 100%; +} + +header { + .nav-link { + display: inline-flex; + align-items: center; + height: 100%; + color: #fff; + padding: 0.5rem; + @media (min-width: 992px) { + padding: 0.5rem 1rem; + } + } + .nav-link:focus, + .nav-link:hover { + color: #fff; + } + + .nav-item:not(.no-border):not(:first-child):before { + content: "|"; + color: #fff; + } + + .nav-link.btn-secondary { + padding: 1rem 2rem; + color: #1E1E1E; + } +} + +.hero { + display: block; + padding: 0; +} + +.home-section { + margin-top: 3rem; + margin-bottom: 3rem; + padding: 0 3rem; + + @media (min-width: 992px) { + margin-top: 6rem; + margin-bottom: 6rem; + } +} +.home-section { + h1 { + font-size: 4rem; + text-align: left; + } + ul { + margin: 0; + } +} + +.heading { + font-size: 4rem; + font-weight: 600; + line-height: 1; + margin-bottom: 1.5rem; +} +.rich-text a { + color: var(--text-color); +} + +.section-title { + font-weight: 600; + font-size: 24px; +} + +.section-text { + font-size: 20px; +} + +footer { + margin-top: 4rem; + border-radius: 2rem; +} +footer div, +footer a { + color: #fff !important; +} +.social-icon { + text-align: center; + padding: 1rem; + background-color: #fff; + border-radius: 0.5rem; + line-height: 0; +} +.social-icon:focus, +.social-icon:hover { + filter: brightness(85%); +} +.social-icon > * { + height: 1rem; + width: 1rem; +} + +.btn { + padding: 1rem 2rem; + border: 1px solid var(--bs-btn-border-color); /* New primary border color */ + color: #1e1e1e; + line-height: 1.125; + transition: all 0.3s ease; +} +.btn:hover { + color: #1e1e1e; +} + +.btn-sm { + padding: 0.25rem !important; +} + +.btn-primary, +.btn-primary:hover { + border: 1px solid transparent; + color: #fff !important; +} + +.btn:hover { + filter: brightness(85%); +} +.btn-google { + height: 56px; + background-color: #f5f5f5; + transition: background-color 0.3s, border-color 0.3s, color 0.3s; +} +.btn-google:hover { + filter: none; + background-color: #e5e5e5; +} +.btn-link { + padding: 0; + height: initial; + text-underline-offset: 0.125rem; +} + +h1 { + margin: 0; + font-size: 2rem; + font-style: normal; + font-weight: 600; +} + +.title h1 { + line-height: 3.7rem; +} + +fieldset { + padding: 0; +} + +dl { + margin: 0; + + dt { + font-weight: 400; + width: fit-content; + } + + dd { + font-size: 1.5rem; + margin-left: 0; + width: fit-content; + margin-bottom: 2rem; + } +} + +.table-responsive { + table { + tr { + height: 51px; + line-height: 1; + } + th { + background-color: #f3f4f5; + font-weight: 600; + } + + th, + td { + padding: 1rem 2rem; + } + border: 1px solid #d9d9d9; + } +} + +.alert { + margin: 0; + border: none; + text-align: left; +} + +.alert-sm { + padding: 0.5rem; + font-size: 0.875rem; + border-radius: 0.25rem; + margin: 0 !important; +} + +.alert-secondary { + background-color: #f3f4f5; +} + +.alert-info { + background-color: #d2dff9; +} + +.text-primary { + color: #289e6d !important; +} + +.text-left { + text-align: left; +} + +.text-right { + text-align: right; +} + +.main-card { + background-color: #fff; + padding: 48px 45px; + border: 1px solid #d9d9d9; + border-radius: 16px; +} +.main-card h1 { + margin-bottom: 1rem; +} + +.px-extra { + padding-left: 5rem !important; + padding-right: 5rem !important; +} + +.subheader { + border-top: 1px #d9d9d9 solid; + border-bottom: 1px #d9d9d9 solid; + + .container { + padding-top: 3rem; + padding-bottom: 3rem; + } +} + +.submenu { + border-bottom: 1px #d9d9d9 solid; + margin-bottom: 1.5rem; + + ul.nav { + margin-left: 0; + } + + .nav-link { + padding: 1rem; + border-bottom: 1px solid transparent; + color: #757575 !important; + } + + .nav-link.active, + .nav-link:hover { + color: #1e1e1e !important; + border-bottom: 2px solid #289e6d; + text-decoration: none !important; + } +} + +.innercontent { + margin-top: 1.5rem; + margin-bottom: 1.5rem; + + ul.nav { + margin-left: 0; + } + + .nav-item { + width: 100%; + } + + .nav-link { + width: 100%; + padding: 1rem; + text-align: left; + color: #1E1E1E !important; + } + + .nav-link.active { + background-color: #e9f5f0; + border-radius: 0.5rem; + font-weight: 500; + color: #289e6d !important; + } + + .nav-link:hover { + font-weight: 500; + text-decoration: none !important; + } + + .alert { + margin-bottom: 2rem; + } +} + +.truecontent { + padding: 5rem; + border-radius: 0.5rem; + background-color: #f3f4f5; +} + +.qr { + padding: 0.5rem; + border-radius: 0.5rem; + border: 1px solid #d9d9d9; + + img { + width: 12.5rem; + height: 12.5rem; + } +} + +.badge { + width: fit-content; + color: #289e6d !important; + background-color: #e9f5f0; + padding: 0.5rem; +} + +.rainbow-border { + --borderWidth: 1px; /* Set border width to 1px */ + position: relative; + overflow: hidden; + z-index: 1; /* Ensure content is above the borders */ +} + +.rainbow-border:after { + content: ""; + position: absolute; + top: calc(-1 * var(--borderWidth)); + left: calc(-1 * var(--borderWidth)); + height: calc(100% + var(--borderWidth) * 2); + width: calc(100% + var(--borderWidth) * 2); + background: linear-gradient(45deg, #fd004c 16.66%, #fe9000 33.33%, #fff020 50%, #3edf4b 66.66%, #3363ff 83.33%, #b102b7 100%); + border-radius: calc(5px + var(--borderWidth)); + z-index: -1; + animation: animatedgradient 30s ease-in-out alternate infinite; + background-size: 400% 100%; +} + +@keyframes animatedgradient { + 0% { + background-position: 0% 50%; + } + + 50% { + background-position: 100% 50%; + } + + 100% { + background-position: 0% 50%; + } +} + +.rainbow-border .card-body { + z-index: 1; /* Ensure content is above all borders */ + position: relative; +} + +.rainbow-border { + .card { + border: none; + } + + .card-header, + .card-footer { + padding: 2rem 3rem; + text-align: left; + } + + .card-header { + background-color: #000; + color: #fff !important; + border: #000; + font-weight: 600; + } + + .card-body { + padding: 2rem 3rem; + } +} + +.main-card-spacing { + padding: 3rem 5rem; +} + +.mx-extra { + margin-left: 6rem; + margin-right: 6rem; +} + +.transferable { + & > .card-header { + padding-top: 0; + padding-bottom: 0; + + button { + padding: 1rem !important; + background: none; + border: 0; + border-bottom: 1px transparent solid; + border-radius: 0; + font-size: 1rem; + + &.active { + border-bottom: 1px red solid; + } + } + } + + & > .card-body { + display: grid !important; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: 1rem; + + .ticket-card { + .card-header { + padding: 0.75rem; + background-color: #000; + color: white !important; + font-size: 0.75rem; + text-align: center; + } + + .btn { + width: fit-content; + padding: 0.5rem 0.75rem !important; + } + + .notice { + width: 100%; + text-align: center; + color: #1e1e1e !important; + font-weight: 400; + + &.bg-success { + background-color: #e9f5f0 !important; + } + + &.bg-warning { + background-color: #fcebcc !important; + } + } + } + } +} + +.modal { + .modal-dialog { + max-width: 700px; + } + + .modal-content { + padding: 2rem 3rem; + } + + .modal-header { + border-bottom: none; + } + + .modal-footer { + border-top: none; + } +} + +.wrapper { + background-color: #f3f4f5; + padding: 5rem; + border-radius: 0.5rem; +} + +.account-wrapper > .container { + background-color: #f3f4f5; + padding: 5rem; + border-radius: 0 0 0.5rem 0.5rem; + margin-bottom: 1rem; + + form { + width: 100%; + } + + input:not(.phone-input), + select { + padding: 1rem !important; + } + + .iti { + width: 100%; + } + + .phone-input { + padding-top: 1rem !important; + padding-bottom: 1rem !important; + border-radius: 0.5rem; + width: 100%; + } +} + +.volunteering { + .card-body { + padding: 3rem 5rem; + } + + .checkbox { + line-height: 1.25rem; + } + + .volunteer-option { + &.border-bottom { + border-bottom: 1px solid #d9d9d9; + } + padding: 1rem 0; + + .alert { + margin-top: 0.5rem !important; + } + } + + label { + font-weight: 600; + } +} + +.form-control { + height: 52px; + line-height: 1; +} +.input-hint { + font-size: 0.75rem; + opacity: 0.75; + margin-top: 0.25rem; +} + +.card-title { + margin-top: 1rem; + margin-bottom: 2rem; +} diff --git a/tickets/static/css/forms.css b/tickets/static/css/forms.css new file mode 100644 index 0000000..fc3b107 --- /dev/null +++ b/tickets/static/css/forms.css @@ -0,0 +1,47 @@ +.quantity-controls { + display: flex; + justify-content: flex-end; + align-items: center; + width: 100%; +} +.quantity-controls > * { + width: 32px; + height: 32px; +} +.quantity-controls button { + font-size: 1.25rem; + border: none; + background: transparent; + color: #b3b3b3; +} +.quantity-controls button:hover { + color: #1d0402; +} + +.quantity-controls input { + border: 1px solid #d9d9d9; + text-align: center; + font-weight: 400; + border-radius: 4px; + width: 28px; + /* hide arrows */ + -moz-appearance: textfield; + -webkit-appearance: textfield; + appearance: textfield; +} +.quantity-controls input::-webkit-inner-spin-button, +.quantity-controls input::-webkit-outer-spin-button, +.donation-input::-webkit-inner-spin-button, +.donation-input::-webkit-outer-spin-button { + -webkit-appearance: none; + margin: 0; +} +/* Hide arrows for Firefox */ +.quantity-controls input, +.donation-input { + -moz-appearance: textfield; /* Change to textfield to remove arrows */ +} + +form .table > :not(caption) > * > * { + padding: 1rem 2rem !important; +} diff --git a/tickets/static/scss/fuego.scss b/tickets/static/css/fuego.css similarity index 56% rename from tickets/static/scss/fuego.scss rename to tickets/static/css/fuego.css index 5385420..64ad9de 100644 --- a/tickets/static/scss/fuego.scss +++ b/tickets/static/css/fuego.css @@ -49,8 +49,8 @@ Text Domain: fuegoaustral */ html { - line-height: 1.15; /* 1 */ - -webkit-text-size-adjust: 100%; /* 2 */ + line-height: 1.15; /* 1 */ + -webkit-text-size-adjust: 100%; /* 2 */ } /* Sections @@ -61,7 +61,7 @@ html { */ body { - margin: 0; + margin: 0; } /** @@ -77,9 +77,9 @@ body { */ hr { - box-sizing: content-box; /* 1 */ - height: 0; /* 1 */ - overflow: visible; /* 2 */ + box-sizing: content-box; /* 1 */ + height: 0; /* 1 */ + overflow: visible; /* 2 */ } /** @@ -88,8 +88,8 @@ hr { */ pre { - font-family: monospace, monospace; /* 1 */ - font-size: 1em; /* 2 */ + font-family: monospace, monospace; /* 1 */ + font-size: 1em; /* 2 */ } /* Text-level semantics @@ -100,7 +100,7 @@ pre { */ a { - background-color: transparent; + background-color: transparent; } /** @@ -109,9 +109,9 @@ a { */ abbr[title] { - border-bottom: none; /* 1 */ - text-decoration: underline; /* 2 */ - text-decoration: underline dotted; /* 2 */ + border-bottom: none; /* 1 */ + text-decoration: underline; /* 2 */ + text-decoration: underline dotted; /* 2 */ } /** @@ -120,7 +120,7 @@ abbr[title] { b, strong { - font-weight: bolder; + font-weight: bolder; } /** @@ -131,8 +131,8 @@ strong { code, kbd, samp { - font-family: monospace, monospace; /* 1 */ - font-size: 1em; /* 2 */ + font-family: monospace, monospace; /* 1 */ + font-size: 1em; /* 2 */ } /** @@ -140,7 +140,7 @@ samp { */ small { - font-size: 80%; + font-size: 80%; } /** @@ -150,18 +150,18 @@ small { sub, sup { - font-size: 75%; - line-height: 0; - position: relative; - vertical-align: baseline; + font-size: 75%; + line-height: 0; + position: relative; + vertical-align: baseline; } sub { - bottom: -0.25em; + bottom: -0.25em; } sup { - top: -0.5em; + top: -0.5em; } /* Embedded content @@ -172,7 +172,7 @@ sup { */ img { - border-style: none; + border-style: none; } /* Forms @@ -188,10 +188,10 @@ input, optgroup, select, textarea { - font-family: inherit; /* 1 */ - font-size: 100%; /* 1 */ - line-height: 1.15; /* 1 */ - margin: 0; /* 2 */ + font-family: inherit; /* 1 */ + font-size: 100%; /* 1 */ + line-height: 1.15; /* 1 */ + margin: 0; /* 2 */ } /** @@ -201,7 +201,7 @@ textarea { button, input { /* 1 */ - overflow: visible; + overflow: visible; } /** @@ -211,7 +211,7 @@ input { /* 1 */ button, select { /* 1 */ - text-transform: none; + text-transform: none; } /** @@ -222,7 +222,7 @@ button, [type="button"], [type="reset"], [type="submit"] { - -webkit-appearance: button; + -webkit-appearance: button; } /** @@ -233,8 +233,8 @@ button::-moz-focus-inner, [type="button"]::-moz-focus-inner, [type="reset"]::-moz-focus-inner, [type="submit"]::-moz-focus-inner { - border-style: none; - padding: 0; + border-style: none; + padding: 0; } /** @@ -245,7 +245,7 @@ button:-moz-focusring, [type="button"]:-moz-focusring, [type="reset"]:-moz-focusring, [type="submit"]:-moz-focusring { - outline: 1px dotted ButtonText; + outline: 1px dotted ButtonText; } /** @@ -253,7 +253,7 @@ button:-moz-focusring, */ fieldset { - padding: 0.35em 0.75em 0.625em; + padding: 0.35em 0.75em 0.625em; } /** @@ -264,12 +264,12 @@ fieldset { */ legend { - box-sizing: border-box; /* 1 */ - color: inherit; /* 2 */ - display: table; /* 1 */ - max-width: 100%; /* 1 */ - padding: 0; /* 3 */ - white-space: normal; /* 1 */ + box-sizing: border-box; /* 1 */ + color: inherit; /* 2 */ + display: table; /* 1 */ + max-width: 100%; /* 1 */ + padding: 0; /* 3 */ + white-space: normal; /* 1 */ } /** @@ -277,7 +277,7 @@ legend { */ progress { - vertical-align: baseline; + vertical-align: baseline; } /** @@ -285,7 +285,7 @@ progress { */ textarea { - overflow: auto; + overflow: auto; } /** @@ -295,8 +295,8 @@ textarea { [type="checkbox"], [type="radio"] { - box-sizing: border-box; /* 1 */ - padding: 0; /* 2 */ + box-sizing: border-box; /* 1 */ + padding: 0; /* 2 */ } /** @@ -305,7 +305,7 @@ textarea { [type="number"]::-webkit-inner-spin-button, [type="number"]::-webkit-outer-spin-button { - height: auto; + height: auto; } /** @@ -314,8 +314,8 @@ textarea { */ [type="search"] { - -webkit-appearance: textfield; /* 1 */ - outline-offset: -2px; /* 2 */ + -webkit-appearance: textfield; /* 1 */ + outline-offset: -2px; /* 2 */ } /** @@ -323,7 +323,7 @@ textarea { */ [type="search"]::-webkit-search-decoration { - -webkit-appearance: none; + -webkit-appearance: none; } /** @@ -332,8 +332,8 @@ textarea { */ ::-webkit-file-upload-button { - -webkit-appearance: button; /* 1 */ - font: inherit; /* 2 */ + -webkit-appearance: button; /* 1 */ + font: inherit; /* 2 */ } /* Interactive @@ -344,7 +344,7 @@ textarea { */ details { - display: block; + display: block; } /* @@ -352,7 +352,7 @@ details { */ summary { - display: list-item; + display: list-item; } /* Misc @@ -363,7 +363,7 @@ summary { */ template { - display: none; + display: none; } /** @@ -371,7 +371,7 @@ template { */ [hidden] { - display: none; + display: none; } /*-------------------------------------------------------------- @@ -383,138 +383,153 @@ input, select, optgroup, textarea { - color: #404040; - font-weight: 300; - font-size: 16px; - font-size: 1rem; - line-height: 1.5; + color: #404040; + font-weight: 300; + font-size: 16px; + font-size: 1rem; + line-height: 1.5; } -h1, h2, h3, h4, h5, h6 { - clear: both; +h1, +h2, +h3, +h4, +h5, +h6 { + clear: both; } p { - margin-bottom: 1.5em; + margin-bottom: 1.5em; } -dfn, cite, em, i { - font-style: italic; +dfn, +cite, +em, +i { + font-style: italic; } blockquote { - margin: 0 1.5em; + margin: 0 1.5em; } address { - margin: 0 0 1.5em; + margin: 0 0 1.5em; } pre { - background: #eee; - font-family: "Courier 10 Pitch", Courier, monospace; - font-size: 15px; - font-size: 0.9375rem; - line-height: 1.6; - margin-bottom: 1.6em; - max-width: 100%; - overflow: auto; - padding: 1.6em; + background: #eee; + font-family: "Courier 10 Pitch", Courier, monospace; + font-size: 15px; + font-size: 0.9375rem; + line-height: 1.6; + margin-bottom: 1.6em; + max-width: 100%; + overflow: auto; + padding: 1.6em; } -code, kbd, tt, var { - font-family: Monaco, Consolas, "Andale Mono", "DejaVu Sans Mono", monospace; - font-size: 15px; - font-size: 0.9375rem; +code, +kbd, +tt, +var { + font-family: Monaco, Consolas, "Andale Mono", "DejaVu Sans Mono", monospace; + font-size: 15px; + font-size: 0.9375rem; } -abbr, acronym { - border-bottom: 1px dotted #666; - cursor: help; +abbr, +acronym { + border-bottom: 1px dotted #666; + cursor: help; } -mark, ins { - background: #fff9c0; - text-decoration: none; +mark, +ins { + background: #fff9c0; + text-decoration: none; } big { - font-size: 125%; + font-size: 125%; } /*-------------------------------------------------------------- # Elements --------------------------------------------------------------*/ html { - box-sizing: border-box; + box-sizing: border-box; } -html, body { - height: 100%; +html, +body { + height: 100%; } *, *:before, *:after { - /* Inherit box-sizing to make it easier to change the property for components that leverage other behavior; see https://css-tricks.com/inheriting-box-sizing-probably-slightly-better-best-practice/ */ - box-sizing: inherit; + /* Inherit box-sizing to make it easier to change the property for components that leverage other behavior; see https://css-tricks.com/inheriting-box-sizing-probably-slightly-better-best-practice/ */ + box-sizing: inherit; } body { - display: flex; - flex-direction: column; - /* Fallback for when there is no custom background color defined. */ - background: #F9F4E9; + display: flex; + flex-direction: column; + /* Fallback for when there is no custom background color defined. */ + background: #f9f4e9; } hr { - background-color: #ccc; - border: 0; - height: 1px; - margin-bottom: 1.5em; + background-color: #ccc; + border: 0; + height: 1px; + margin-bottom: 1.5em; } -ul, ol { - margin: 0 0 1.5em 3em; +ul, +ol { + margin: 0 0 1.5em 3em; } ul { - list-style: disc; + list-style: disc; } ol { - list-style: decimal; + list-style: decimal; } li > ul, li > ol { - margin-bottom: 0; - margin-left: 1.5em; + margin-bottom: 0; + margin-left: 1.5em; } dt { - font-weight: bold; + font-weight: bold; } dd { - margin: 0 1.5em 1.5em; + margin: 0 1.5em 1.5em; } img { - height: auto; - /* Make sure images are scaled correctly. */ - max-width: 100%; - /* Adhere to container width. */ + height: auto; + /* Make sure images are scaled correctly. */ + max-width: 100%; + /* Adhere to container width. */ } figure { - margin: 1em 0; - /* Extra wide images within figure tags don't overflow the content area. */ + margin: 1em 0; + /* Extra wide images within figure tags don't overflow the content area. */ } table { - margin: 0 0 1.5em; - width: 100%; + margin: 0 0 1.5em; + width: 100%; } /*-------------------------------------------------------------- @@ -524,32 +539,33 @@ button, input[type="button"], input[type="reset"], input[type="submit"] { - border: 1px solid; - border-color: #ccc #ccc #bbb; - border-radius: 3px; - background: #e6e6e6; - color: rgba(0, 0, 0, 0.8); - font-size: 12px; - font-size: 0.75rem; - line-height: 1; - padding: .6em 1em .4em; + border: 1px solid; + border-color: #ccc #ccc #bbb; + border-radius: 3px; + background: #e6e6e6; + color: rgba(0, 0, 0, 0.8); + font-size: 12px; + font-size: 0.75rem; + line-height: 1; + padding: .6em 1em .4em; } button:hover, input[type="button"]:hover, input[type="reset"]:hover, input[type="submit"]:hover { - border-color: #ccc #bbb #aaa; + border-color: #ccc #bbb #aaa; } -button:active, button:focus, +button:active, +button:focus, input[type="button"]:active, input[type="button"]:focus, input[type="reset"]:active, input[type="reset"]:focus, input[type="submit"]:active, input[type="submit"]:focus { - border-color: #aaa #bbb #bbb; + border-color: #aaa #bbb #bbb; } input[type="text"], @@ -568,10 +584,10 @@ input[type="datetime"], input[type="datetime-local"], input[type="color"], textarea { - color: #666; - border: 1px solid #ccc; - border-radius: 3px; - padding: 3px; + color: #666; + border: 1px solid #ccc; + border-radius: 3px; + padding: 3px; } input[type="text"]:focus, @@ -590,15 +606,15 @@ input[type="datetime"]:focus, input[type="datetime-local"]:focus, input[type="color"]:focus, textarea:focus { - color: #111; + color: #111; } select { - border: 1px solid #ccc; + border: 1px solid #ccc; } textarea { - width: 100%; + width: 100%; } /*-------------------------------------------------------------- @@ -608,180 +624,187 @@ textarea { ## Links --------------------------------------------------------------*/ a { - color: royalblue; + color: royalblue; } a:visited { - color: purple; + color: purple; } -a:hover, a:focus, a:active { - color: midnightblue; +a:hover, +a:focus, +a:active { + color: midnightblue; } a:focus { - outline: thin dotted; + outline: thin dotted; } -a:hover, a:active { - outline: 0; +a:hover, +a:active { + outline: 0; } /*-------------------------------------------------------------- ## Menus --------------------------------------------------------------*/ .menu-toggle { - margin-top: 30px; - float: right; - border: 1px solid rgba(255, 255, 255, 0.5) + margin-top: 30px; + float: right; + border: 1px solid rgba(255, 255, 255, 0.5); } .main-navigation.toggled ul { - margin-top: 10px; - background-color: #F2EBDB; - border-radius: 4px; - border: 1px solid #C3B59F; - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + margin-top: 10px; + background-color: #f2ebdb; + border-radius: 4px; + border: 1px solid #c3b59f; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); } .main-navigation.toggled li { - border-radius: 0; - display: block; - width: 100%; - text-shadow: none; - border-bottom: 1px solid #C3B59F; + border-radius: 0; + display: block; + width: 100%; + text-shadow: none; + border-bottom: 1px solid #c3b59f; } .main-navigation.toggled li:last-child { - border-bottom: none; + border-bottom: none; } .main-navigation.toggled a { - color: #503C3A; + color: #503c3a; } .main-navigation .navbar-toggler-icon { - background-image: url("data:image/svg+xml,%3csvg viewBox='0 0 30 30' xmlns='http://www.w3.org/2000/svg'%3e%3cpath stroke='rgba(255, 255, 255, 0.5)' stroke-width='2' stroke-linecap='round' stroke-miterlimit='10' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e"); + background-image: url("data:image/svg+xml, %3csvg viewBox='0 0 30 30' xmlns='http://www.w3.org/2000/svg'%3e%3cpath stroke='rgba(255, 255, 255, 0.5)' stroke-width='2' stroke-linecap='round' stroke-miterlimit='10' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e"); } .main-navigation ul { - display: none; - list-style: none; - margin: 50px 0 0; - padding-left: 0; - float: right; + display: none; + list-style: none; + margin: 50px 0 0; + padding-left: 0; + float: right; } .main-navigation ul ul { - margin-top: 0; - border-radius: 4px; - box-shadow: 0 3px 3px rgba(0, 0, 0, 0.2); - float: left; - position: absolute; - top: 100%; - left: -999em; - z-index: 99999; + margin-top: 0; + border-radius: 4px; + box-shadow: 0 3px 3px rgba(0, 0, 0, 0.2); + float: left; + position: absolute; + top: 100%; + left: -999em; + z-index: 99999; } .main-navigation ul ul ul { - left: -999em; - top: 0; + left: -999em; + top: 0; } .main-navigation ul ul li:hover > ul, .main-navigation ul ul li.focus > ul { - left: 100%; + left: 100%; } .main-navigation ul ul a { - width: 200px; - color: #736868; - text-shadow: none; + width: 200px; + color: #736868; + text-shadow: none; } .main-navigation ul ul li { - background: white; - border-radius: 0; + background: white; + border-radius: 0; } .main-navigation ul ul li.current_page_item { - background: white; + background: white; } .main-navigation ul ul li:first-child { - border-radius: 4px 4px 0 0; + border-radius: 4px 4px 0 0; } .main-navigation ul ul li:last-child { - border-radius: 0 0 4px 4px; + border-radius: 0 0 4px 4px; } .main-navigation ul ul li:hover { - background-color: #f7f5f0; - color: #4e4343; + background-color: #f7f5f0; + color: #4e4343; } .main-navigation ul li:hover > ul, .main-navigation ul li.focus > ul { - left: auto; + left: auto; } .main-navigation li { - float: left; - position: relative; - text-transform: uppercase; - font-size: 15px; - font-weight: 600; - text-shadow: 0 1px 2px rgba(0, 0, 0, 0.2); - border-radius: 28px; + float: left; + position: relative; + text-transform: uppercase; + font-size: 15px; + font-weight: 600; + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.2); + border-radius: 28px; } .main-navigation a { - display: block; - text-decoration: none; - color: white; - padding: 8px 18px; + display: block; + text-decoration: none; + color: white; + padding: 8px 18px; } -.main-navigation .current_page_item, .main-navigation .current-page-ancestor { - background-color: rgba(255, 255, 255, 0.1); +.main-navigation .current_page_item, +.main-navigation .current-page-ancestor { + background-color: rgba(255, 255, 255, 0.1); } /* Small menu. */ .menu-toggle, .main-navigation.toggled ul { - display: block; + display: block; } @media screen and (min-width: 1024px) { - .menu-toggle { - display: none; - } - .main-navigation ul { - display: block; - } + .menu-toggle { + display: none; + } + + .main-navigation ul { + display: block; + } } -.site-main .comment-navigation, .site-main -.posts-navigation, .site-main +.site-main .comment-navigation, +.site-main +.posts-navigation, +.site-main .post-navigation { - margin: 0 0 1.5em; - overflow: hidden; + margin: 0 0 1.5em; + overflow: hidden; } .comment-navigation .nav-previous, .posts-navigation .nav-previous, .post-navigation .nav-previous { - float: left; - width: 50%; + float: left; + width: 50%; } .comment-navigation .nav-next, .posts-navigation .nav-next, .post-navigation .nav-next { - float: right; - text-align: right; - width: 50%; + float: right; + text-align: right; + width: 50%; } /*-------------------------------------------------------------- @@ -789,65 +812,65 @@ a:hover, a:active { --------------------------------------------------------------*/ /* Text meant only for screen readers. */ .screen-reader-text { - border: 0; - clip: rect(1px, 1px, 1px, 1px); - clip-path: inset(50%); - height: 1px; - margin: -1px; - overflow: hidden; - padding: 0; - position: absolute !important; - width: 1px; - word-wrap: normal !important; /* Many screen reader and browser combinations announce broken words as they would appear visually. */ + border: 0; + clip: rect(1px, 1px, 1px, 1px); + clip-path: inset(50%); + height: 1px; + margin: -1px; + overflow: hidden; + padding: 0; + position: absolute !important; + width: 1px; + word-wrap: normal !important; /* Many screen reader and browser combinations announce broken words as they would appear visually. */ } .screen-reader-text:focus { - background-color: #f1f1f1; - border-radius: 3px; - box-shadow: 0 0 2px 2px rgba(0, 0, 0, 0.6); - clip: auto !important; - clip-path: none; - color: #21759b; - display: block; - font-size: 14px; - font-size: 0.875rem; - font-weight: bold; - height: auto; - left: 5px; - line-height: normal; - padding: 15px 23px 14px; - text-decoration: none; - top: 5px; - width: auto; - z-index: 100000; - /* Above WP toolbar. */ + background-color: #f1f1f1; + border-radius: 3px; + box-shadow: 0 0 2px 2px rgba(0, 0, 0, 0.6); + clip: auto !important; + clip-path: none; + color: #21759b; + display: block; + font-size: 14px; + font-size: 0.875rem; + font-weight: bold; + height: auto; + left: 5px; + line-height: normal; + padding: 15px 23px 14px; + text-decoration: none; + top: 5px; + width: auto; + z-index: 100000; + /* Above WP toolbar. */ } /* Do not show the outline on the skip link target. */ #content[tabindex="-1"]:focus { - outline: 0; + outline: 0; } /*-------------------------------------------------------------- # Alignments --------------------------------------------------------------*/ .alignleft { - display: inline; - float: left; - margin-right: 1.5em; + display: inline; + float: left; + margin-right: 1.5em; } .alignright { - display: inline; - float: right; - margin-left: 1.5em; + display: inline; + float: right; + margin-left: 1.5em; } .aligncenter { - clear: both; - display: block; - margin-left: auto; - margin-right: auto; + clear: both; + display: block; + margin-left: auto; + margin-right: auto; } /*-------------------------------------------------------------- @@ -865,9 +888,9 @@ a:hover, a:active { .site-content:after, .site-footer:before, .site-footer:after { - content: ""; - display: table; - table-layout: fixed; + content: ""; + display: table; + table-layout: fixed; } .clear:after, @@ -876,95 +899,89 @@ a:hover, a:active { .site-header:after, .site-content:after, .site-footer:after { - clear: both; + clear: both; } /*-------------------------------------------------------------- # Widgets --------------------------------------------------------------*/ .widget { - margin: 0 0 1.5em; - /* Make sure select elements fit in widgets. */ + margin: 0 0 1.5em; + /* Make sure select elements fit in widgets. */ } .widget select { - max-width: 100%; + max-width: 100%; } /*-------------------------------------------------------------- # Content --------------------------------------------------------------*/ .content { - flex: 1 0 auto; + flex: 1 0 auto; } /*-------------------------------------------------------------- ## Header and brand --------------------------------------------------------------*/ -@media screen and (min-width: 1200px) { - .container { - max-width: 800px; - } -} - .site-header { - background: rgb(252, 0, 6) url('https://faticketera-zappa-prod.s3.amazonaws.com/img/hero.jpg') no-repeat top center; - background-size: 1680px; - height: 140px; + background: rgb(252, 0, 6) url('https://faticketera-zappa-prod.s3.amazonaws.com/img/hero.jpg') no-repeat top center; + height: 200px; } .site-header.hero { - height: 620px; + background-size: cover; + height: 620px; +} + +@media screen and (max-width: 767px) { + .site-header.hero { + height: 300px; + } } .site-branding { - margin-top: 30px; + margin-top: 30px; } .whatis { - background-color: black; - color: #F4EAD6; + background-color: black; + color: #f4ead6; } .whatis p { - font-size: 25px; - text-align: center; - line-height: 1.6em; - margin: 0 0 40px; + font-size: 25px; + text-align: center; + line-height: 1.6em; + margin: 0 0 40px; } -@media screen and (max-width:767px) { - .site-header.hero { - background-size: 767px; - height: 300px; - } +@media screen and (max-width: 767px) { + .whatis p { + font-size: 20px; + line-height: 1.8em; + } - .whatis p { - font-size: 20px; - line-height: 1.8em; - } + .site-branding { + float: left; + width: initial; + } - .site-branding { - float: left; - width: initial; - } - - .main-navigation-wrap { - top: -100px; - } + .main-navigation-wrap { + top: -100px; + } } /*-------------------------------------------------------------- ## Principles --------------------------------------------------------------*/ .principles { - background-color: black; - color: #F4EAD6; + background-color: black; + color: #f4ead6; } - .principles a { - color: #F4EAD6; + color: #f4ead6; } .principles a:hover { @@ -972,294 +989,293 @@ a:hover, a:active { } .principles p { - font-size: 16px; - line-height: 2em; + font-size: 16px; + line-height: 2em; } .principles ul { - list-style: none; - margin: 0; - padding: 0; + list-style: none; + margin: 0; + padding: 0; } -@media screen and (min-width:768px) { - .principles ul { - columns: 2; - -webkit-columns: 2; - -moz-columns: 2; - } +@media screen and (min-width: 768px) { + .principles ul { + columns: 2; + -webkit-columns: 2; + -moz-columns: 2; + } } .principles li { - padding: 15px; - font-size: 18px; - font-weight: 400; + padding: 15px; + font-size: 18px; + font-weight: 400; } .principles li img { - margin-right: 15px; - transition: transform 1s; + margin-right: 15px; + transition: transform 1s; } .principles li:hover img { - transform: scale(1.1); + transform: scale(1.1); } /*-------------------------------------------------------------- ## Current edition --------------------------------------------------------------*/ .current-edition { - background-size: 1680px; - padding: 80px 0 0; + background-size: 1680px; + padding: 80px 0 0; } -@media screen and (max-width:1440px) { - .current-edition { - background-size: 1440px; - } +@media screen and (max-width: 1440px) { + .current-edition { + background-size: 1440px; + } } -@media screen and (max-width:1024px) { - .current-edition { - background-size: 1280px; - } +@media screen and (max-width: 1024px) { + .current-edition { + background-size: 1280px; + } } -@media screen and (max-width:767px) { - .current-edition { - background-size: 1000px; - } +@media screen and (max-width: 767px) { + .current-edition { + background-size: 1000px; + } } .current-edition .card { - box-shadow: 0 5px 9px rgba(0, 0, 0, 0.5); - border: none; - border-radius: 0; - color: #503C3A; + box-shadow: 0 5px 9px rgba(0, 0, 0, 0.5); + border: none; + border-radius: 0; + color: #503c3a; } .current-edition .card-header { - padding-left: 40px; - background-color: #F9F4E9; - border-bottom-color: #D8CDB5; - text-transform: uppercase; - font-weight: 600; - color: #a9a091; - font-size: 14px; + padding-left: 40px; + background-color: #f9f4e9; + border-bottom-color: #d8cdb5; + text-transform: uppercase; + font-weight: 600; + color: #a9a091; + font-size: 14px; } .current-edition .card-body { - padding: 40px; + padding: 40px; } .current-edition .card-actions { - background-color: #F9F4E9; - border-top: 1px solid #D8CDB5; - padding: 30px 40px; + background-color: #f9f4e9; + border-top: 1px solid #d8cdb5; + padding: 30px 40px; } - .current-edition .card h3 { - font-size: 30px; - color: #ACA4A3; - margin-bottom: 20px; + font-size: 30px; + color: #aca4a3; + margin-bottom: 20px; } .current-edition .card p { - font-size: 15px; - line-height: 1.8em; + font-size: 15px; + line-height: 1.8em; } .current-edition .btn { - border: none; - text-transform: uppercase; - font-size: 14px; - font-weight: bold; - padding: 10px 23px; - border-radius: 23px; + border: none; + text-transform: uppercase; + font-size: 14px; + font-weight: bold; + padding: 10px 23px; + border-radius: 23px; } .current-edition .btn-primary { - background: #FF7B26; - box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.15); - color: white; - text-shadow: 0 1px 1px rgba(0, 0, 0, 0.2); + background: #ff7b26; + box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.15); + color: white; + text-shadow: 0 1px 1px rgba(0, 0, 0, 0.2); } .current-edition .btn-primary:not(:disabled):not(.disabled):active { - background: #f1711e; + background: #f1711e; } .current-edition .btn-primary:not(:disabled):not(.disabled):active:focus { - box-shadow: 0 0 0 0.2rem rgba(240, 113, 30, 0.34); + box-shadow: 0 0 0 0.2rem rgba(240, 113, 30, 0.34); } .current-edition .card-actions .btn-default { - box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.15); - background: #E8E0CF; - color: #796C6B; + box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.15); + background: #e8e0cf; + color: #796c6b; } -@media screen and (max-width:767px) { - .current-edition { - padding-top: 10px; - } +@media screen and (max-width: 767px) { + .current-edition { + padding-top: 10px; + } - .current-edition .container { - padding-left: 10px; - padding-right: 10px; - } + .current-edition .container { + padding-left: 10px; + padding-right: 10px; + } - .current-edition .card-body { - padding: 20px; - } + .current-edition .card-body { + padding: 20px; + } - .current-edition .card-header { - padding-left: 20px; - } + .current-edition .card-header { + padding-left: 20px; + } - .current-edition .card h3 { - font-size: 20px; - } + .current-edition .card h3 { + font-size: 20px; + } - .current-edition .card p { - font-size: 14px; - } + .current-edition .card p { + font-size: 14px; + } } /*-------------------------------------------------------------- ## No spectators --------------------------------------------------------------*/ .no-spectators { - color: #D6C4A1; + color: #d6c4a1; } .no-spectators a { - color: #D6C4A1; + color: #d6c4a1; } .no-spectators .lead { - font-size: 1.25rem; - line-height: 1.8em; + font-size: 1.25rem; + line-height: 1.8em; } .no-spectators h3 { - font-size: 28px; - margin-bottom: 15px; + font-size: 28px; + margin-bottom: 15px; } .no-spectators p { - color: #D6C4A1; - font-size: 15px; - line-height: 1.8em; + color: #d6c4a1; + font-size: 15px; + line-height: 1.8em; } .no-spectators img { - border: 2px solid white; + border: 2px solid white; } /*-------------------------------------------------------------- ## Footer --------------------------------------------------------------*/ .site-footer { - flex-shrink: 0; - background-color: #180707; - padding-top: 20px; - color: rgba(255, 255, 255, 0.5); - font-size: 14px; + flex-shrink: 0; + background-color: #180707; + padding-top: 20px; + color: rgba(255, 255, 255, 0.5); + font-size: 14px; } .site-footer a { - color: rgba(255, 255, 255, 0.5); - text-decoration: underline; + color: rgba(255, 255, 255, 0.5); + text-decoration: underline; } .site-footer .container { - padding: 40px 20px; + padding: 40px 20px; } .home .site-footer .container { - border-top: 1px solid rgba(255, 255, 255, 0.15); + border-top: 1px solid rgba(255, 255, 255, 0.15); } .site-footer .footer-logo { - background: url('https://faticketera-zappa-prod.s3.amazonaws.com/img/logo.svg') no-repeat top center; - opacity: 0.4; - background-size: 136px 88px; - height: 88px; + background: url('https://faticketera-zappa-prod.s3.amazonaws.com/img/logo.svg') no-repeat top center; + opacity: 0.4; + background-size: 136px 88px; + height: 88px; } .site-footer .social { - text-align: right; + text-align: right; } .site-footer .social a { - text-decoration: none; + text-decoration: none; } -@media screen and (max-width:767px) { - .site-footer .info { - text-align: center; - margin-bottom: 20px; - } +@media screen and (max-width: 767px) { + .site-footer .info { + text-align: center; + margin-bottom: 20px; + } - .site-footer .footer-logo { - margin-bottom: 20px; - } + .site-footer .footer-logo { + margin-bottom: 20px; + } - .site-footer .social { - text-align: center; - } + .site-footer .social { + text-align: center; + } } .site-main { - color: #735b59; + color: #735b59; } .site-main hr { - background-color: #e6dbcb; - margin: 40px 0; - clear: both; + background-color: #e6dbcb; + margin: 40px 0; + clear: both; } .site-main .lead { - line-height: 1.8em; + line-height: 1.8em; } .site-main h3 { - display: inline-block; + display: inline-block; } .site-main a { - color: #503C3A; - text-decoration: underline; + color: #503c3a; + text-decoration: underline; } .site-main .wp-block-image img { - border: 7px solid white; - box-shadow: 0 4px 9px rgba(0, 0, 0, 0.2); + border: 7px solid white; + box-shadow: 0 4px 9px rgba(0, 0, 0, 0.2); } .site-main ul { - margin: 0 0 30px 0; + margin: 0 0 30px 0; } .site-main ol { - margin: 0; + margin: 0; } .site-main li { - margin-bottom: 10px; - padding-left: 5px; + margin-bottom: 10px; + padding-left: 5px; } .site-main pre { - background: #f1eadc; + background: #f1eadc; } .site-main .card { - background-color: #F2EBDB; - border-color: #C3B59F; + background-color: #f2ebdb; + border-color: #c3b59f; } .site-main a.card:hover { @@ -1271,54 +1287,54 @@ a:hover, a:active { } .site-main .card-img { - background: #C3B59F; + background: #c3b59f; } .site-main .card-title { - font-weight: 400; - font-size: 28px; + font-weight: 400; + font-size: 28px; } .site-main .card-text { - font-size: 15px; + font-size: 15px; } /*-------------------------------------------------------------- ## Posts and pages --------------------------------------------------------------*/ .sticky { - display: block; + display: block; } .post, .page { - margin: 0 0 1.5em; + margin: 0 0 1.5em; } .updated:not(.published) { - display: none; + display: none; } .page-content, .entry-content, .entry-summary { - margin: 1.5em 0 0; + margin: 1.5em 0 0; } .page-links { - clear: both; - margin: 0 0 1.5em; + clear: both; + margin: 0 0 1.5em; } /*-------------------------------------------------------------- ## Comments --------------------------------------------------------------*/ .comment-content a { - word-wrap: break-word; + word-wrap: break-word; } .bypostauthor { - display: block; + display: block; } /*-------------------------------------------------------------- @@ -1327,13 +1343,13 @@ a:hover, a:active { /* Globally hidden elements when Infinite Scroll is supported and in use. */ .infinite-scroll .posts-navigation, .infinite-scroll.neverending .site-footer { - /* Theme Footer (when set to scrolling) */ - display: none; + /* Theme Footer (when set to scrolling) */ + display: none; } /* When Infinite Scroll has reached its end we need to re-display elements that were hidden (via .neverending) before. */ .infinity-end.neverending .site-footer { - display: block; + display: block; } /*-------------------------------------------------------------- @@ -1342,88 +1358,88 @@ a:hover, a:active { .page-content .wp-smiley, .entry-content .wp-smiley, .comment-content .wp-smiley { - border: none; - margin-bottom: 0; - margin-top: 0; - padding: 0; + border: none; + margin-bottom: 0; + margin-top: 0; + padding: 0; } /* Make sure embeds and iframes fit their containers. */ embed, iframe, object { - max-width: 100%; + max-width: 100%; } /*-------------------------------------------------------------- ## Captions --------------------------------------------------------------*/ .wp-caption { - margin-bottom: 1.5em; - max-width: 100%; + margin-bottom: 1.5em; + max-width: 100%; } .wp-caption img[class*="wp-image-"] { - display: block; - margin-left: auto; - margin-right: auto; + display: block; + margin-left: auto; + margin-right: auto; } .wp-caption .wp-caption-text { - margin: 0.8075em 0; + margin: 0.8075em 0; } .wp-caption-text { - text-align: center; + text-align: center; } /*-------------------------------------------------------------- ## Galleries --------------------------------------------------------------*/ .gallery { - margin-bottom: 1.5em; + margin-bottom: 1.5em; } .gallery-item { - display: inline-block; - text-align: center; - vertical-align: top; - width: 100%; + display: inline-block; + text-align: center; + vertical-align: top; + width: 100%; } .gallery-columns-2 .gallery-item { - max-width: 50%; + max-width: 50%; } .gallery-columns-3 .gallery-item { } .gallery-columns-4 .gallery-item { - max-width: 25%; + max-width: 25%; } .gallery-columns-5 .gallery-item { - max-width: 20%; + max-width: 20%; } .gallery-columns-6 .gallery-item { - max-width: 16.66%; + max-width: 16.66%; } .gallery-columns-7 .gallery-item { - max-width: 14.28%; + max-width: 14.28%; } .gallery-columns-8 .gallery-item { - max-width: 12.5%; + max-width: 12.5%; } .gallery-columns-9 .gallery-item { - max-width: 11.11%; + max-width: 11.11%; } .gallery-caption { - display: block; + display: block; } /*-------------------------------------------------------------- @@ -1431,62 +1447,62 @@ object { --------------------------------------------------------------*/ .wp-block-advanced-gutenberg-blocks-notice { - border-top: 3px solid #bba3a1; - border-radius: 0; - background-color: white; - padding: 20px; - margin-bottom: 20px; + border-top: 3px solid #bba3a1; + border-radius: 0; + background-color: white; + padding: 20px; + margin-bottom: 20px; } .wp-block-advanced-gutenberg-blocks-notice__title { - font-size: 14px; - font-weight: bold; - text-transform: uppercase; - margin-bottom: 5px; + font-size: 14px; + font-weight: bold; + text-transform: uppercase; + margin-bottom: 5px; } .wp-block-advanced-gutenberg-blocks-notice__content { - margin: 0; + margin: 0; } .wp-block-advanced-gutenberg-blocks-notice.is-variation-warning { - border-top-color: #b38924; + border-top-color: #b38924; } .wp-block-advanced-gutenberg-blocks-notice.is-variation-warning.is-style-full { - color: #735b59; - background-color: #ffe4a4; + color: #735b59; + background-color: #ffe4a4; } .wp-block-advanced-gutenberg-blocks-notice.is-variation-warning .wp-block-advanced-gutenberg-blocks-notice__title { - color: #b38924; + color: #b38924; } .wp-block-advanced-gutenberg-blocks-summary { - background-color: #f1eadd; - margin-left: 20px; - float: right; + background-color: #f1eadd; + margin-left: 20px; + float: right; } .wp-block-advanced-gutenberg-blocks-summary ol ol { - margin-left: 0; - padding-left: 15px; + margin-left: 0; + padding-left: 15px; } .wp-block-advanced-gutenberg-blocks-summary li { - list-style: none; + list-style: none; } .wp-block-advanced-gutenberg-blocks-summary a { - text-decoration: none; + text-decoration: none; } .wp-block-advanced-gutenberg-blocks-summary__list { - padding: 0; + padding: 0; } .wp-block-button .wp-block-button__link { - background-color: #190606; - text-decoration: none; - color: #F9F4E9; + background-color: #190606; + text-decoration: none; + color: #f9f4e9; } diff --git a/tickets/static/css/global.css b/tickets/static/css/global.css new file mode 100644 index 0000000..08b67c2 --- /dev/null +++ b/tickets/static/css/global.css @@ -0,0 +1,304 @@ + +/* Typography */ + +html { + font-size: 16px !important; +} + +body { + background: #f8f7f4; + text-align: center; + color: #0d0d21; + font-family: 'Open Sans', sans-serif; + font-family: 'Rubik', sans-serif !important; + font-weight: 100; +} + +h1 { + font-size: 4.2rem; + font-weight: 100; + line-height: normal; +} + +h2 { + font-size: 2rem; + font-weight: 100; +} + +h2 .icon { + font-size: 3rem; + margin-bottom: 1rem; +} + +.small-title { + font-size: 1.625rem; + font-style: normal; + line-height: 165.2%; /* 2.6845rem */ + background-color: #eadef9; +} + +p { + font-size: 1.2rem; + line-height: 1.7rem; +} + +b { + font-weight: 400; +} + +p a { + font-weight: 400; +} + +.text-white { + color: white; +} + +/* Layout */ + +h1 { + max-width: 900px; +} + +h2, +p { + max-width: 800px; +} + +h1, +h2, +p, +img.full-width { + margin-left: auto; + margin-right: auto; + margin-bottom: 4rem !important; +} + +.to-left { + text-align: left !important; +} + +.centered { + margin-left: auto; + margin-right: auto; + display: block; + width: fit-content; + padding-left: 1rem; + padding-right: 1rem; +} + +.full-alert { + width: 100%; + background: #fee0d1; + color: red; + padding: 5rem 0; + + p { + margin-bottom: 0 !important; + } +} + +.no-bottom-margin { + margin-bottom: 0 !important; +} + +.section { + padding-top: 7rem; + padding-bottom: 7rem; +} + +.container-fluid { + padding: 0 !important; +} + +.two-columns { + margin-left: auto; + margin-right: auto; + max-width: 1200px; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(350px, 1fr)); + column-gap: 40px; + text-align: left !important; +} + +.two-columns .column { +} + +.boxes { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: center; + gap: 20px; +} + +.box { + max-width: 22rem; + min-width: 22rem; + aspect-ratio: 1; + background-color: #f8f7f4; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 0 1.8rem; + border-radius: 40px; + gap: 1rem; +} + +.box .button { + background-color: black; + border-radius: 40px; + line-height: 1rem; + text-decoration: none; + font-size: 1rem; + padding: 1.0625rem 4.5rem; +} + +.box p, +.box h2 { + margin-bottom: 0 !important; +} + +.box .icon { + font-size: 1.6rem; +} + +img.full-width { + max-height: 19rem; +} + +.white-bg { + background-color: white; +} + +.pb-7 { + padding-bottom: 7rem; +} + +/* Extra small devices (phones, 600px and down) */ +@media only screen and (max-width: 600px) { + h1 { + font-size: 3.8rem; + } + + .site-main { + padding: 0 .5rem !important; + } + + .box { + max-width: 21rem; + min-width: 21rem; + } + + .whire-bg, + .full-alert { + padding-left: 0.5rem; + padding-right: 0.5rem; + width: calc(100% + 1rem); + margin-left: -0.5rem; + } + + img.full-width { + min-width: calc(100% + 1rem); + margin-left: -0.5rem; + } +} + +.site-main { + margin-left: auto !important; + margin-right: auto !important; +} + +@media screen and (max-width: 767px) { + .whatis p { + font-size: 20px; + line-height: 1.8em; + } + + .site-branding { + float: left; + width: initial; + } + + .main-navigation-wrap { + top: -100px; + } +} + +.emoji { + font-size: 2rem; +} + +.list-group { + .list-group-item { + text-decoration: none; + padding: 2rem; + + p { + margin: 0; + } + + .emoji { + margin-top: -1.5rem; + margin-right: -1rem; + } + } + + .card { + margin-bottom: 1rem; + } +} + +.form-group { + margin: 0.5rem 0 1rem 0; + text-align: left; +} + +.ticket-form { + margin: 1rem 0 2rem 0; + + .delete-row { + color: #503c3a; + height: 2.5rem; + } +} + +.form-control { + padding-left: 0.75rem !important; + background-position: 0.75rem center; + background-size: 16px; + background-repeat: no-repeat; + border: #eeeeee 1px solid; + + &::placeholder { + color: #999; + } +} + +dl { + display: flex; + justify-content: between; + margin-bottom: 0.5rem; + + dt { + width: 6rem; + } + + dd { + margin: 0 0 0 1rem; + } +} + +.btn.btn-submit, +.cho-container .mercadopago-button { + background-color: #503c3a; + line-height: 1rem; + color: lighten(#503c3a, 60%); + font-size: 1.4rem; + padding: 1rem; + border-radius: 0.125rem; + cursor: pointer; + border: 0; + width: 100%; +} diff --git a/tickets/static/img/google.png b/tickets/static/img/google.png new file mode 100644 index 0000000..cb96f19 Binary files /dev/null and b/tickets/static/img/google.png differ diff --git a/tickets/static/scss/global.scss b/tickets/static/scss/global.scss deleted file mode 100644 index fec3dbe..0000000 --- a/tickets/static/scss/global.scss +++ /dev/null @@ -1,299 +0,0 @@ - -/* Typography */ - -html { - font-size: 16px !important; -} - -body { - background: #F8F7F4; - text-align: center; - color: #0D0D21; - font-family: 'Open Sans', sans-serif; - font-family: 'Rubik', sans-serif !important; - font-weight: 100; -} - -h1 { - font-size: 4.2rem; - font-weight: 100; - line-height: normal; -} - -h2 { - font-size: 2rem; - font-weight: 100; -} - -h2 .icon { - font-size: 3rem; - margin-bottom: 1rem; -} - -.small-title { - font-size: 1.625rem; - font-style: normal; - line-height: 165.2%; /* 2.6845rem */ - background-color: #EADEF9; -} - -p { - font-size: 1.2rem; - line-height: 1.7rem; -} - -b { - font-weight: 400; -} - -p a { - font-weight: 400; -} - -.text-white { - color: white; -} - -/* Layout */ - -h1 { - max-width: 900px; -} - -h2, p { - max-width: 800px; -} - -h1, h2, p, img.full-width { - margin-left: auto; - margin-right: auto; - margin-bottom: 4rem !important; -} - -.to-left { - text-align: left !important; -} -.centered { - margin-left: auto; - margin-right: auto; - display: block; - width: fit-content; - padding-left: 1rem; - padding-right: 1rem; -} - -.full-alert { - width: 100%; - background: #FEE0D1; - color: red; - padding: 5rem 0; - p { - margin-bottom: 0 !important; - } -} - -.no-bottom-margin { - margin-bottom: 0 !important; -} - -.section { - padding-top: 7rem; - padding-bottom: 7rem; -} - - -.container-fluid { - padding: 0 !important; -} - -.two-columns { - margin-left: auto; - margin-right: auto; - max-width: 1200px; - display: grid; - grid-template-columns: repeat(auto-fit, minmax(350px, 1fr)); - column-gap: 40px; - text-align: left !important; -} - -.two-columns .column { -} - - -.boxes { - display: flex; - flex-wrap: wrap; - align-items: center; - justify-content: center; - gap: 20px -} -.box { - max-width: 22rem; - min-width: 22rem; - aspect-ratio: 1; - background-color: #f8f7f4; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - padding: 0 1.8rem; - border-radius: 40px; - gap: 1rem; -} - -.box .button { - background-color: black; - border-radius: 40px; - line-height: 1rem; - text-decoration: none; - font-size: 1rem; - padding: 1.0625rem 4.5rem; -} - -.box p, .box h2 { - margin-bottom: 0 !important; -} - -.box .icon { - font-size: 1.6rem; -} - -img.full-width { - max-height: 19rem; -} - -.white-bg { - background-color: white; -} - -.pb-7 { - padding-bottom: 7rem; -} - -/* Extra small devices (phones, 600px and down) */ -@media only screen and (max-width: 600px) { - h1 { - font-size: 3.8rem; - } - - .site-main { - padding: 0 .5rem !important; - } - - .box { - max-width: 21rem; - min-width: 21rem; - } - - .whire-bg, .full-alert { - padding-left: 0.5rem; - padding-right: 0.5rem; - width: calc(100% + 1rem); - margin-left: -0.5rem; - } - - img.full-width { - min-width: calc(100% + 1rem); - margin-left: -0.5rem; - } - -} - - - - - - - - -.site-main { - margin-top: 7rem; - margin-left: auto !important; - margin-right: auto !important; -} - -@media screen and (max-width: 767px) { - .site-header.hero { - background-size: 767px; - height: 240px; } - .whatis p { - font-size: 20px; - line-height: 1.8em; } - .site-branding { - float: left; - width: initial; } - .main-navigation-wrap { - top: -100px; } } - -.emoji { font-size: 2rem; -} - -.list-group { - .list-group-item { - text-decoration: none; - padding: 2rem; - p { - margin: 0; - } - .emoji { - margin-top: -1.5rem; - margin-right: -1rem; - } - } - .card { - margin-bottom: 1rem; - } -} - -.form-group { - margin: 0.5rem 0 1rem 0; - text-align: left; -} - -.ticket-form { - margin: 1rem 0 2rem 0; - - .delete-row { - color: #503C3A; - height: 2.5rem; - } - - -} - - -.form-control { - padding-left: 0.75rem !important; - background-position: 0.75rem center; - background-size: 16px; - background-repeat: no-repeat; - border: #eeeeee 1px solid; - &::placeholder { - color: #999; - } -} - -dl { - display: flex; - justify-content: between; - margin-bottom: 0.5rem; - dt { - width: 6rem; - } - dd { - margin: 0 0 0 1rem; - } -} - -.btn.btn-submit, -.cho-container .mercadopago-button { - background-color: #503C3A; - line-height: 1rem; - color: lighten(#503C3A, 60%); - font-size: 1.4rem; - padding: 1rem; - border-radius: 0.125rem; - cursor: pointer; - border: 0; - width: 100%; -} diff --git a/tickets/templates/admin/admin_caja.html b/tickets/templates/admin/admin_caja.html new file mode 100644 index 0000000..2554d4c --- /dev/null +++ b/tickets/templates/admin/admin_caja.html @@ -0,0 +1,345 @@ +{% extends "admin/base_site.html" %} +{% load static %} +{% load humanize %} + +{% block extrahead %} + + + +{% endblock %} + +{% block content %} +
+
+

Emision directa de bonos

+
+ {% csrf_token %} + +
+ + {{ user.email }} +
+
+
+
+ + +
+
+
+ +
+ {% csrf_token %} + +
+
+
+ + +
+
+
+ + +
+ + +
+
+
+

+ Total: $0.00 +

+
+
+ +
+
+
+ + +{% endblock %} diff --git a/tickets/templates/admin/admin_caja_summary.html b/tickets/templates/admin/admin_caja_summary.html new file mode 100644 index 0000000..758ce93 --- /dev/null +++ b/tickets/templates/admin/admin_caja_summary.html @@ -0,0 +1,80 @@ +{% extends "admin/base_site.html" %} +{% load static %} + +{% block extrahead %} + + +{% endblock %} + +{% block content %} +
+ +
+ + + + + +
+ +

+ BONOS EMITIDOS EXITOSAMENTE +

+ {% if new_user %} +
+

Un usuario fue creado durante esta orden. Informarle a la persona que se le enviaron dos + emails, la confirmacion de la orden, y un link para resetear su password

+

+ Poder acceder a su cuenta debe usar: {{ order.user.email }} +

+

+ Si es una cuenta de gmail puede acceder directamente con google. +

+

+ desde su cuenta podra gestionar sus bonos, y transferirlos a otras personas. +

+
+ {% endif %} +
+

Resumen de siguiente orden {{ order.id }}

+ + +

Nombre: {{ order.user.first_name }} {{ order.user.last_name }}

+

Email: {{ order.user.email }}

+ +

Se han emitido los siguientes bonos:

+
    + {% for ticket in tickets %} +
  • {{ ticket.ticket_type.name }} - {{ ticket.key }}
  • + {% endfor %} +
+

+

El total de la orden es de ${{ order.amount }}

+

+ + +
+ + +{% endblock %} diff --git a/tickets/templates/admin/admin_direct_tickets.html b/tickets/templates/admin/admin_direct_tickets.html new file mode 100644 index 0000000..8ed161f --- /dev/null +++ b/tickets/templates/admin/admin_direct_tickets.html @@ -0,0 +1,469 @@ +{% extends "admin/base_site.html" %} +{% load static %} + +{% block extrahead %} + + + +{% endblock %} + +{% block content %} +
+
+

Bonos Dirigidos

+ {% if ticket_type %} +
+ {% csrf_token %} + +
+ + {{ user.email }} +
+
+
+
+ + +
+
+
+ +
+ {% csrf_token %} + +
+
+
+ + +
+
+
+ + +
+ + + +
+
+
+

+ Total: $0.00 +

+
+
+ +
+ {% else %} +
+ El evento no tiene bonos dirigidos configurados o disponibles +
+ {% endif %} +
+
+ + +{% endblock %} diff --git a/tickets/templates/admin/admin_direct_tickets_buyer.html b/tickets/templates/admin/admin_direct_tickets_buyer.html new file mode 100644 index 0000000..94776ea --- /dev/null +++ b/tickets/templates/admin/admin_direct_tickets_buyer.html @@ -0,0 +1,165 @@ +{% extends "admin/base_site.html" %} +{% load static %} + +{% block extrahead %} + + + +{% endblock %} + +{% block content %} +
+
+

Resumen de Bonos Dirigidos

+ + + + + + + + + + {% for ticket in tickets %} + + + + + {% endfor %} + +
Nombre del BonoCantidad
{{ ticket.origin }} - {{ ticket.name }} {{ ticket.amount }}
+ +

+ +
+ Email: {{ email }} +
+ {% if not user %} +
+ Repetir email: + +
+ {% endif %} + +
+ Tipo orden: {{ order_type }} +
+
+ Notas: {{ notes }} +
+ +
+ {% csrf_token %} + + + Volver + + +
+
+
+ + +{% endblock %} diff --git a/tickets/templates/admin/admin_direct_tickets_congrats.html b/tickets/templates/admin/admin_direct_tickets_congrats.html new file mode 100644 index 0000000..398cd64 --- /dev/null +++ b/tickets/templates/admin/admin_direct_tickets_congrats.html @@ -0,0 +1,124 @@ +{% extends "admin/base_site.html" %} +{% load static %} + +{% block extrahead %} + + + +{% endblock %} + +{% block content %} +
+
+

¡Compraste tu/s bono/s dirigido/s!

+ +

Vas a recibir un email para crearte un usuario y gestionar tus bonos

+ +
+ Email: {{ order.email }} +
+ + + + + + + + {% for ticket in tickets %} + + + + + + {% endfor %} + + + + + + + + + +{% endblock %} diff --git a/tickets/templates/admin/base_site.html b/tickets/templates/admin/base_site.html new file mode 100644 index 0000000..47d69e8 --- /dev/null +++ b/tickets/templates/admin/base_site.html @@ -0,0 +1,28 @@ +{% extends "admin/base.html" %} + + + + +{% block title %} + {{ title }} | {{ site_title|default:_('Django site admin') }} +{% endblock %} + +{% block branding %} +

{{ site_header|default:_('Django administration') }}

+{% endblock %} + +{% block nav-global %} + +{% endblock %} diff --git a/tickets/templates/checkout/base.html b/tickets/templates/checkout/base.html new file mode 100644 index 0000000..ae60e97 --- /dev/null +++ b/tickets/templates/checkout/base.html @@ -0,0 +1,18 @@ +{% extends '../tickets/barbu_base.html' %} +{% load static %} +{% block extrahead %} + {{ block.super }} + +{% endblock %} +{% block page_title %} + Comprar bono +{% endblock page_title %} +{% block menu_container %} +{% endblock menu_container %} +{% block innercontent %} +
+{% block truecontent %} +{% endblock truecontent %} +
+{% endblock innercontent %} + diff --git a/tickets/templates/checkout/order_summary.html b/tickets/templates/checkout/order_summary.html new file mode 100644 index 0000000..b0e6abd --- /dev/null +++ b/tickets/templates/checkout/order_summary.html @@ -0,0 +1,72 @@ +{% extends "./base.html" %} +{% load humanize %} + +{% block truecontent %} + + {% load static %} +
+
+
Paso 3 de 4
+

Resumen de tu Pedido

+ +
+ {% csrf_token %} + +
+
TicketEvento
{{ ticket.key }}{{ ticket.event }}
Total{{ tickets.count }}
+ + + + + + + + + + {% for ticket in ticket_data %} + {% if ticket.quantity > 0 %} + + + + + + + {% endif %} + {% endfor %} + {% for donation in donation_data %} + {% if donation.quantity > 0 %} + + + + + + + {% endif %} + {% endfor %} + +
DescripciónPrecioCantidadSubtotal
{{ ticket.name }}${{ ticket.price|floatformat|intcomma }}{{ ticket.quantity }}${{ ticket.subtotal|floatformat|intcomma }}
{{ donation.name }}  ${{ donation.subtotal|floatformat|intcomma }}
+
+ +
+
Precios expresados en pesos argentinos
+
Total $ {{ total_amount|floatformat|intcomma }}
+
+ +
+ Atrás + +
+ +
+
+ + +{% endblock %} diff --git a/tickets/templates/checkout/payment_callback.html b/tickets/templates/checkout/payment_callback.html new file mode 100644 index 0000000..2b59985 --- /dev/null +++ b/tickets/templates/checkout/payment_callback.html @@ -0,0 +1,64 @@ +{% extends "./base.html" %} +{% block truecontent %} +
+
+
+

Procesando tu pago...

+
Estamos verificando el estado de tu pago. Por favor, espera un momento.
+
+ Loading... +
+
+
+

¡Pago Completado con éxito!

+
+ Tu pago ha sido procesado correctamente y tus bonos han sido emitidos. +
+ Podes encontrarlos en la sección “Bonos” en tu perfil. +
+ Ir a Bonos +
+
+

Pago Devuelto

+
+ Se agotaron los bonos y tu pago fue devuelto. +
+ Estate atento a las novedades de la comunidad para el JORTEO. +
+
+
+
+ + +{% endblock %} diff --git a/tickets/templates/checkout/select_donations.html b/tickets/templates/checkout/select_donations.html new file mode 100644 index 0000000..36b0547 --- /dev/null +++ b/tickets/templates/checkout/select_donations.html @@ -0,0 +1,232 @@ +{% extends "./base.html" %} +{% block truecontent %} +
+
+
Paso 2 de 4
+

Donaciones

+ +
+ {% csrf_token %} + {% if form.errors %} +
+ {% for field in form %} + {% for error in field.errors %} + {{ field.label }}: {{ error }} +
+ {% endfor %} + {% endfor %} + {% for error in form.non_field_errors %} + {{ error }} +
+ {% endfor %} +
+ {% endif %} + +
+ + + + + + + + + + + + + + + + + + + + + +
Tipo de DonaciónMonto
+ Becas de Arte +
+ Para impulsar la creatividad en nuestra ciudad temporal. +
+ + + + + + + + + +
+ Beca Inclusión Radical +
+ Para ayudar a quienes necesitan una mano con su bono contribución. +
+ + + + + + + + + +
+ Donaciones a La Sede +
+ Para mejorar el espacio donde nos encontramos todo el año. +
+ + + + + + + + + +
+
+
+
Precios expresados en pesos argentinos
+
Subtotal Donaciones $ 0
+
+
+ Atrás + +
+ +
+
+
+ + +{% endblock %} diff --git a/tickets/templates/checkout/select_tickets.html b/tickets/templates/checkout/select_tickets.html new file mode 100644 index 0000000..367c53d --- /dev/null +++ b/tickets/templates/checkout/select_tickets.html @@ -0,0 +1,159 @@ +{% extends "./base.html" %} +{% load humanize %} +{% block truecontent %} + {% if tickets_remaining <= 0 %} +
+
+
+

Tickets agotados 😕

+
Estate atento a las novedades de la comunidad para el JORTEO.
+
+
+
+ {% else %} +
+
+
Paso 1 de 4
+

Bonos {{ event.name }}

+ +
+ {% csrf_token %} + {% if form.errors %} +
+ {% for field in form %} + {% for error in field.errors %} + {{ field.label }}: {{ error }} +
+ {% endfor %} + {% endfor %} + {% for error in form.non_field_errors %} + {{ error }} +
+ {% endfor %} +
+ {% endif %} +
+ + + + + + + + + + {% for ticket in ticket_data %} + + + + + + {% endfor %} + +
Tipo de BonoPrecioCantidad
+
{{ ticket.name }}
+ {{ ticket.description }} +
${{ ticket.price|floatformat|intcomma }} + + + + + +
+
+
+
Precios expresados en pesos argentinos
+
Subtotal Bonos $0
+
+ +
+
+
+ + {% endif %} +{% endblock %} diff --git a/tickets/templates/emails/base.html b/tickets/templates/emails/base.html index c14d100..f08168d 100644 --- a/tickets/templates/emails/base.html +++ b/tickets/templates/emails/base.html @@ -9,7 +9,6 @@ - Todo Permuta diff --git a/tickets/templates/emails/new_transfer_no_account.html b/tickets/templates/emails/new_transfer_no_account.html new file mode 100644 index 0000000..e64ade7 --- /dev/null +++ b/tickets/templates/emails/new_transfer_no_account.html @@ -0,0 +1,42 @@ +{% extends 'emails/base.html' %} +{% block subject %}🔥¡Te enviaron un bono de Fuego Austral, no cuelges en registrarte!🔥 {% endblock %} +{% block title %} + Transferencia de Bono
+{% endblock %} +{% block content %} +

+ Te han enviado {{ ticket_count }} {% if ticket_count > 1 %}bonos{% else %}bono{% endif %} de Fuego Austral. + Para completar la transferencia y recibir tu{% if ticket_count > 1 %}s bonos{% else %} bono{% endif %}, + necesitas crear una cuenta en nuestra plataforma. +

+ +

+ Crear una cuenta es muy fácil, solo haz click en el siguiente botón y sigue las instrucciones. +

+ + {% include 'emails/partials/btn.html' with label='Crear cuenta' href=sign_up_link domain=domain %} + +

+ Es importante que uses el mismo email al que te enviamos este mensaje ({{ destination_email }}). +

+

+

+ +

+ Cualquier problema que tengas con tu{% if ticket_count > 1 %}s bonos{% else %} bono{% endif %}, podés escribirnos a bonos@fuegoaustral.org + o chatearnos desde la web. +

+

+ La experiencia será lo que hagamos de ella. +

+ + + + + +
+

+ LA REVENTA CON SOBREPRECIOS IMPLICARÁ LA CANCELACIÓN DEL BONO CONTRIBUCIÓN +

+
+{% endblock %} diff --git a/tickets/templates/emails/new_transfer_success.html b/tickets/templates/emails/new_transfer_success.html new file mode 100644 index 0000000..2d2e546 --- /dev/null +++ b/tickets/templates/emails/new_transfer_success.html @@ -0,0 +1,32 @@ +{% extends 'emails/base.html' %} +{% block subject %}🔥¡Te enviaron un bono de Fuego Austral!🔥{% endblock %} +{% block title %} + Transferencia de Bono
+{% endblock %} +{% block content %} +

+ Te han enviado {{ ticket_count }} + {% if ticket_count > 1 %}bonos{% else %}bono{% endif %} + de Fuego Austral. Podés verlo{% if ticket_count > 1 %}s{% endif %} entrando en el siguiente enlace: +

+ + {% include 'emails/partials/btn.html' with label='Mis Bonos' href='/mi-fuego/mis-bonos' domain=domain %} + +

+ Cualquier problema que tengas con tu{% if ticket_count > 1 %}s bonos{% else %} bono{% endif %}, podés escribirnos a + bonos@fuegoaustral.org o chatearnos desde la web. +

+

+ La experiencia será lo que hagamos de ella. +

+ + + + + +
+

+ LA REVENTA CON SOBREPRECIOS IMPLICARÁ LA CANCELACIÓN DEL BONO CONTRIBUCIÓN +

+
+{% endblock %} diff --git a/tickets/templates/emails/order_success.html b/tickets/templates/emails/order_success.html index b8c14e8..c775d35 100644 --- a/tickets/templates/emails/order_success.html +++ b/tickets/templates/emails/order_success.html @@ -1,19 +1,32 @@ {% extends 'emails/base.html' %} -{% block subject %}Tu orden para {{ event.name|safe }} ha sido confirmada{% endblock %} +{% block subject %}🔥Tu orden para {{ event.name|safe }} ha sido confirmada🔥{% endblock %} {% block title %} ¡TODO EN ORDEN! {% endblock %} {% block content %} -

- Tu Orden de Compra #{{ order.id }} para {{ event.name|safe }} ha sido: -

+

+ Tu Orden de Compra #{{ order.id }} para {{ event.name|safe }} ha sido: +

-

CONFIRMADA

+

CONFIRMADA

-

En breve recibirán los bonos contribución de ingreso a los correos cargados. Cualquier problema que tengas, escribinos a: bonos@fuegoaustral.org

+

Podes ver tus bonos entrando a -

ESTE EMAIL NO ES UN BONO CONTRIBUCIÓN DE INGRESO

+ {% include 'emails/partials/btn.html' with label='Mis Bonos' href='/mi-fuego/mis-bonos' domain=domain %} -

La experiencia será lo que hagamos de ella.

+ {% if has_many_tickets %} +

Recorda que cada persona debe tener su bono en su cuenta. Si compraste mas de uno, debes transferiselo a + la + persona + correspondiente.

+

Tenes tiempo hasta el {{ event.transfers_enabled_until|date:"d/m" }} para hacerlo.

+ {% endif %} + + +

Cualquier problema que tengas, escribinos a: bonos@fuegoaustral.org, + o por el chat en la web.

+ + +

La experiencia será lo que hagamos de ella.

{% endblock %} diff --git a/tickets/templates/emails/recipient_pending_transfers_reminder.html b/tickets/templates/emails/recipient_pending_transfers_reminder.html new file mode 100644 index 0000000..cd1a461 --- /dev/null +++ b/tickets/templates/emails/recipient_pending_transfers_reminder.html @@ -0,0 +1,38 @@ +{% extends 'emails/base.html' %} +{% block subject %}🚨 ¡No olvides registrarte! Tienes un bono pendiente en Fuego Austral 🚨{% endblock %} +{% block title %} + Recordatorio de Transferencia de Bono
+{% endblock %} +{% block content %} +

+ ¡Hola! Queremos recordarte que tienes un bono pendiente en Fuego Austral. La transferencia fue iniciada + hace {{ transfer.max_days_ago }} días y para poder recibir tu bono, es necesario que crees una cuenta en nuestra + plataforma. +

+ +

+ Crear tu cuenta es muy sencillo. Solo haz clic en el botón de abajo y sigue las instrucciones. +

+ + {% include 'emails/partials/btn.html' with label='Crear cuenta' href=sign_up_link domain=domain %} + +

+ Recuerda usar el mismo email al que te enviamos este mensaje ({{ transfer.tx_to_email }}) para completar + tu registro. +

+ +

+ Tienes tiempo hasta el {{ current_event.transfers_enabled_until|date:"d/m" }} para finalizar este proceso y + recibir tu bono. +

+ +

+ Si tienes alguna duda o inconveniente con tu bono, no dudes en contactarnos a través de bonos@fuegoaustral.org o chatear con nosotros directamente desde + nuestra web. +

+ +

+ ¡La experiencia será tan especial como tú la hagas! +

+{% endblock %} diff --git a/tickets/templates/emails/sender_pending_transfers_reminder.html b/tickets/templates/emails/sender_pending_transfers_reminder.html new file mode 100644 index 0000000..3dbacc3 --- /dev/null +++ b/tickets/templates/emails/sender_pending_transfers_reminder.html @@ -0,0 +1,56 @@ +{% extends 'emails/base.html' %} +{% block subject %}🔔 Recordatorio: Tus bonos aún no han sido aceptados 🔔{% endblock %} +{% block title %} + Recordatorio de Transferencia de Bonos
+{% endblock %} +{% block content %} +

+ ¡Hola! Queremos recordarte que los bonos que compartiste aún no han sido aceptados. Han + pasado {{ transfer.max_days_ago }} días desde que realizaste la transferencia. +

+ +

+ Aquí tienes el detalle de los bonos pendientes: +

+ +
    + {% for recipient in transfer.tx_to_emails %} +
  • + {{ listita_emojis|random }} {{ recipient.tx_to_email }}: {{ recipient.pending_tickets }} bono(s) + pendiente(s) +
  • + {% endfor %} +
+ +

+ Por favor, verifica si los destinatarios van a usar los bonos y si las direcciones de correo electrónico son + correctas. Si necesitas hacer algún cambio, aún tienes tiempo. +

+ + {% include 'emails/partials/btn.html' with label='Mis Bonos' href='/mi-fuego/mis-bonos' domain=domain %} + +

+ Tienes tiempo hasta el {{ current_event.transfers_enabled_until|date:"d/m" }} para asegurarte de que todo esté + en orden y los bonos puedan ser disfrutados. +

+ +

+ Si necesitas asistencia o tienes alguna pregunta, no dudes en contactarnos a través de bonos@fuegoaustral.org o chatear con nosotros directamente desde + nuestra web. +

+ +

+ ¡Asegurémonos de que todos puedan disfrutar de esta experiencia única! +

+ + + + + +
+

+ LA REVENTA CON SOBREPRECIOS IMPLICARÁ LA CANCELACIÓN DEL BONO CONTRIBUCIÓN +

+
+{% endblock %} diff --git a/tickets/templates/emails/unsent_tickets_reminder.html b/tickets/templates/emails/unsent_tickets_reminder.html new file mode 100644 index 0000000..86f16c2 --- /dev/null +++ b/tickets/templates/emails/unsent_tickets_reminder.html @@ -0,0 +1,34 @@ +{% extends 'emails/base.html' %} +{% block subject %}🎟️ ¡Recuerda compartir tus bonos pendientes en Fuego Austral! 🎟️{% endblock %} +{% block title %} + Recordatorio para Compartir Bonos
+{% endblock %} +{% block content %} +

+ ¡Hola! Queremos recordarte que tienes {{ unsent_ticket.pending_to_share_tickets }} bonos pendientes por compartir. Han pasado {{ unsent_ticket.max_days_ago }} días desde que recibiste estos bonos y aún no los has compartido. +

+ +

+ Asegúrate de compartirlos con tus amigos antes del {{ current_event.transfers_enabled_until|date:"d/m" }} para que todos puedan disfrutar de esta experiencia. +

+ + {% include 'emails/partials/btn.html' with label='Compartir bonos' href=share_tickets_link domain=domain %} + +

+ Si necesitas ayuda o tienes alguna pregunta, no dudes en contactarnos a través de bonos@fuegoaustral.org o chatea con nosotros directamente desde nuestra web. +

+ +

+ ¡Vamos a asegurarnos de que todos puedan disfrutar al máximo de Fuego Austral! +

+ + + + + +
+

+ LA REVENTA CON SOBREPRECIOS IMPLICARÁ LA CANCELACIÓN DEL BONO CONTRIBUCIÓN +

+
+{% endblock %} diff --git a/tickets/templates/tickets/barbu_base.html b/tickets/templates/tickets/barbu_base.html new file mode 100644 index 0000000..c46d3f4 --- /dev/null +++ b/tickets/templates/tickets/barbu_base.html @@ -0,0 +1,201 @@ + +{% load static %} +{% load account %} + + + + + + + + + + + + + + + {% block title %} + {% endblock title %} + Fuego Austral – La experiencia será lo que hagamos de ella + {% block extrahead %}{% endblock %} + {% if user.is_authenticated and user.profile.profile_completion == 'COMPLETE' %} + + {% endif %} + + +
+ +
+ {% block full_content %} +
+
+
+

+ {% block page_title %} + Mi Perfil + {% endblock page_title %} +

+
+ {% block actions %} + {% endblock actions %} +
+
+
+
+ {% block menu_container %} + + {% endblock menu_container %} + {% block alerts %} + {% endblock alerts %} +
+
+ {% block innercontent %} +
+
+ +
+
+ {% block truecontent %} + {% endblock truecontent %} +
+
+ {% endblock innercontent %} +
+
+ {% endblock full_content %} + +
+ + + diff --git a/tickets/templates/tickets/base.html b/tickets/templates/tickets/base.html index ca16de2..736c50f 100644 --- a/tickets/templates/tickets/base.html +++ b/tickets/templates/tickets/base.html @@ -1,74 +1,211 @@ {% load static %} -{% load pipeline %} +{% load account %} - - - - - - + + + + + + + + + - {% stylesheet 'main' %} + + + + {% block title %}{% endblock title %}Fuego Austral – La experiencia será lo que hagamos de ella - Fuego Austral – La experiencia será lo que hagamos de ella {% block extrahead %}{% endblock %} + {% if user.is_authenticated and user.profile.profile_completion == 'COMPLETE' %} + + {% endif %} - -
- - +
+ -
+ +
+ + + + {% block full_content %} +
{% block content %}{% endblock %}
+ {% endblock %} - + + diff --git a/tickets/templates/tickets/home.html b/tickets/templates/tickets/home.html index 21c0e2a..c41c8e2 100644 --- a/tickets/templates/tickets/home.html +++ b/tickets/templates/tickets/home.html @@ -1,114 +1,92 @@ -{% extends 'tickets/base.html' %} - -{% block hero %}hero{% endblock %} - -{% block container_type %}container-fluid{% endblock %} - -{% block content %} - - {% if event.active %} -
- {{ event.description|safe }} -
- -
- - {% for ticket_type in ticket_types %} - - - - {% empty %} - -
-
- -
-

Lo sentimos

-

No hay bonos disponibles en este momento.

+{% extends 'tickets/barbu_base.html' %} +{% load humanize %} +{% block full_content %} + {% if event %} + + {{ event.name }} + +
+
+
+

+ Sobre +
+ el evento +

- - {% endfor %} +
{{ event.description|safe }}
+
- - {% else %} -
-
-
-
- +
+
+
+ {% endif %} +
+
+
+

Donaciones

+
+
+
+ Toda creación de la comunidad en Fuego Austral o en La Sede + requiere de mucho trabajo, procesos y materiales. +
+
+ Por eso, además de adquirir tu bono contribución de ingreso, + podés colaborar con tu donación para impulsar la + creatividad, ayudar a quienes necesiten una mano con su bono + contribución, para mejorar el espacio donde nos encontramos + todo el año, y más.
-

Lo sentimos

-

No hay bonos disponibles en este momento.

- {% endif %} - - - - - -
- -
-
-

Donaciones

+ +
+
+
+
Becas de Arte
+
Para impulsar la creatividad en nuestra ciudad temporal.
-
-

- Toda creación de la comunidad en Fuego Austral o en La Sede requiere de mucho trabajo, procesos y materiales.
- Por eso, además de adquirir tu bono contribución de ingreso, podes colaborar con algunas de las siguientes propuestas: -

+
- -
- -
-
- -
-

Becas de Arte

-

Para impulsar la creatividad en nuestra ciudad temporal.

- Donar +
+
+
Inclusión Radical
+
Para ayudar a quienes necesitan una mano con su bono contribución.
- -
-
- -
-

Inclusión Radical

-

Para ayudar a quienes necesitan una mano con su bono contribución.

- Donar + - - -
-
- - -
-

La Sede

-

Para mejorar el espacio donde nos encontramos todo el año.

- Donar +
+
+
+
La Sede
+
Para mejorar el espacio donde nos encontramos todo el año.
+
+ -
-
- {% endblock %} diff --git a/tickets/templates/tickets/order_detail.html b/tickets/templates/tickets/order_detail.html index 6681e94..9861d6c 100644 --- a/tickets/templates/tickets/order_detail.html +++ b/tickets/templates/tickets/order_detail.html @@ -1,6 +1,5 @@ {% extends 'tickets/base.html' %} {% load bootstrap5 %} -{% load pipeline %} {% block extrahead %} {% if order.amount > 0 %} diff --git a/tickets/templates/tickets/order_new.html b/tickets/templates/tickets/order_new.html index 4136603..94d0203 100644 --- a/tickets/templates/tickets/order_new.html +++ b/tickets/templates/tickets/order_new.html @@ -1,11 +1,11 @@ {% extends 'tickets/base.html' %} {% load bootstrap5 %} -{% load pipeline %} - +{% load static %} {% block extrahead %} - {% stylesheet 'main' %} + + {% endblock %} {% block content %} diff --git a/tickets/templates/tickets/ticket_detail.html b/tickets/templates/tickets/ticket_detail.html index a302baa..02ccc32 100644 --- a/tickets/templates/tickets/ticket_detail.html +++ b/tickets/templates/tickets/ticket_detail.html @@ -1,6 +1,5 @@ {% extends 'tickets/base.html' %} {% load bootstrap5 %} -{% load pipeline %} {% block content %} diff --git a/tickets/templates/tickets/ticket_transfer.html b/tickets/templates/tickets/ticket_transfer.html index ad95222..e36eb0a 100644 --- a/tickets/templates/tickets/ticket_transfer.html +++ b/tickets/templates/tickets/ticket_transfer.html @@ -1,6 +1,5 @@ {% extends 'tickets/base.html' %} {% load bootstrap5 %} -{% load pipeline %} {% block content %} diff --git a/tickets/templates/tickets/ticket_transfer_confirmation.html b/tickets/templates/tickets/ticket_transfer_confirmation.html index 6a4ae16..f7ba846 100644 --- a/tickets/templates/tickets/ticket_transfer_confirmation.html +++ b/tickets/templates/tickets/ticket_transfer_confirmation.html @@ -1,6 +1,5 @@ {% extends 'tickets/base.html' %} {% load bootstrap5 %} -{% load pipeline %} {% block content %} diff --git a/tickets/templates/tickets/ticket_transfer_confirmed.html b/tickets/templates/tickets/ticket_transfer_confirmed.html index ba6d139..533a969 100644 --- a/tickets/templates/tickets/ticket_transfer_confirmed.html +++ b/tickets/templates/tickets/ticket_transfer_confirmed.html @@ -1,6 +1,5 @@ {% extends 'tickets/base.html' %} {% load bootstrap5 %} -{% load pipeline %} {% block content %} diff --git a/tickets/templates/tickets/ticket_transfer_expired.html b/tickets/templates/tickets/ticket_transfer_expired.html index a35d40b..37fee54 100644 --- a/tickets/templates/tickets/ticket_transfer_expired.html +++ b/tickets/templates/tickets/ticket_transfer_expired.html @@ -1,6 +1,5 @@ {% extends 'tickets/base.html' %} {% load bootstrap5 %} -{% load pipeline %} {% block content %} diff --git a/tickets/templatetags/form_filters.py b/tickets/templatetags/form_filters.py new file mode 100644 index 0000000..c11173a --- /dev/null +++ b/tickets/templatetags/form_filters.py @@ -0,0 +1,16 @@ +from django import template + +from tickets.models import TicketType + +register = template.Library() + + +@register.filter +def get_ticket_price(ticket_types, field_name): + # Extract ticket ID from the field name + ticket_id = field_name.split('_')[-1] + try: + ticket = ticket_types.get(id=ticket_id) + return ticket.price + except TicketType.DoesNotExist: + return 0 diff --git a/tickets/urls.py b/tickets/urls.py index 4912a34..f7910f3 100644 --- a/tickets/urls.py +++ b/tickets/urls.py @@ -1,17 +1,45 @@ from django.urls import path -from . import views + +from .views import home, order, ticket, checkout, webhooks, new_ticket urlpatterns = [ - path('', views.home, name='home'), - path('new-order//', views.order, name='order'), - path('order/', views.order_detail, name='order_detail'), - path('order//payments/success', views.payment_success, name='payment_success_callback'), - path('order//payments/failure', views.payment_failure, name='payment_failure_callback'), - path('order//payments/pending', views.payment_pending, name='payment_pending_callback'), - path('order//confirm', views.free_order_confirmation, name='free_order_confirmation'), - path('ticket/', views.ticket_detail, name='ticket_detail'), - path('ticket//transfer', views.ticket_transfer, name='ticket_transfer'), - path('ticket//transfer/confirmation', views.ticket_transfer_confirmation, name='ticket_transfer_confirmation'), - path('ticket//confirmed', views.ticket_transfer_confirmed, name='ticket_transfer_confirmed'), - path('payments/ipn/', views.payment_notification, name='payment_notification'), + path('', home.home, name='home'), + + # Order related paths + path('new-order//', order.order, name='order'), + path('order/', order.order_detail, name='order_detail'), + path('order//payments/success', order.payment_success, name='payment_success_callback'), + path('order//payments/failure', order.payment_failure, name='payment_failure_callback'), + path('order//payments/pending', order.payment_pending, name='payment_pending_callback'), + path('order//confirm', order.free_order_confirmation, name='free_order_confirmation'), + path('payments/ipn/', order.payment_notification, name='payment_notification'), + path('checkout/payment-callback/', order.checkout_payment_callback, + name='checkout_payment_callback'), + path('checkout/check-order-status/', order.check_order_status, name='check_order_status'), + + # Ticket related paths + + path('ticket/transfer-ticket', new_ticket.transfer_ticket, name='transfer_ticket'), + path('ticket/transfer-ticket/cancel-ticket-transfer', new_ticket.cancel_ticket_transfer, + name='cancel_ticket_transfer'), + + path('ticket/', ticket.ticket_detail, name='ticket_detail'), + path('ticket//transfer', ticket.ticket_transfer, name='ticket_transfer'), + path('ticket//transfer/confirmation', ticket.ticket_transfer_confirmation, + name='ticket_transfer_confirmation'), + path('ticket//confirmed', ticket.ticket_transfer_confirmed, name='ticket_transfer_confirmed'), + + # Checkout related paths + path('checkout/select-tickets', checkout.select_tickets, name='select_tickets'), + path('checkout/select-donations', checkout.select_donations, name='select_donations'), + path('checkout/order-summary', checkout.order_summary, name='order_summary'), + + # Webhook related paths + path('webhooks/mercadopago', webhooks.mercadopago_webhook, name='mercadopago_webhook'), + + path('ticket//assign', new_ticket.assign_ticket, name='assign_ticket'), + path('ticket//unassign', new_ticket.unassign_ticket, name='unassign_ticket'), + + path('ping/', home.ping, name='ping') + ] diff --git a/tickets/views/checkout.py b/tickets/views/checkout.py new file mode 100644 index 0000000..be60a54 --- /dev/null +++ b/tickets/views/checkout.py @@ -0,0 +1,213 @@ +import uuid + +import mercadopago +from django.conf import settings +from django.contrib.auth.decorators import login_required +from django.db import transaction +from django.http import HttpResponse, HttpResponseRedirect +from django.shortcuts import render, redirect +from django.urls import reverse + +from tickets.forms import CheckoutTicketSelectionForm, CheckoutDonationsForm +from tickets.models import Event, TicketType, Order, OrderTicket + + +@login_required +def select_tickets(request): + if request.method == 'POST': + form = CheckoutTicketSelectionForm(request.POST, user=request.user) + if form.is_valid(): + request.session['ticket_selection'] = form.cleaned_data + return redirect('select_donations') + else: + event = Event.objects.get(active=True) + tickets_remaining = event.tickets_remaining() or 0 + available_tickets = event.max_tickets_per_order + available_tickets = min(available_tickets, tickets_remaining) + return render(request, 'checkout/select_tickets.html', { + 'form': form, + 'ticket_data': form.ticket_data, + 'available_tickets': available_tickets, + 'tickets_remaining': tickets_remaining + }) + + event = Event.objects.get(active=True) + tickets_remaining = event.tickets_remaining() or 0 + available_tickets = event.max_tickets_per_order + available_tickets = min(available_tickets, tickets_remaining) + + initial_data = request.session.get('ticket_selection', {}) + + if 'new' in request.GET or request.session.get('order_sid') is None: + request.session['order_sid'] = str(uuid.uuid4()) + request.session['event_id'] = event.id + request.session.pop('ticket_selection', None) + request.session.pop('donations', None) + ticket_id = request.GET.get('ticket_id') + if ticket_id: + initial_data[f'ticket_{ticket_id}_quantity'] = 1 + + form = CheckoutTicketSelectionForm(initial=initial_data) + + return render(request, 'checkout/select_tickets.html', { + 'form': form, + 'ticket_data': form.ticket_data, + 'available_tickets': available_tickets, + 'tickets_remaining': tickets_remaining + }) + + +@login_required +def select_donations(request): + if request.method == 'POST': + form = CheckoutDonationsForm(request.POST) + if form.is_valid(): + request.session['donations'] = form.cleaned_data + return redirect('order_summary') + + if 'new' in request.GET or request.session.get('order_sid') is None: + request.session['order_sid'] = str(uuid.uuid4()) + request.session.pop('ticket_selection', None) + request.session.pop('donations', None) + + initial_data = request.session.get('donations', {}) + form = CheckoutDonationsForm(initial=initial_data) + + return render(request, 'checkout/select_donations.html', { + 'form': form, + 'ticket_selection': request.session.get('ticket_selection', None), + }) + + +@login_required +def order_summary(request): + if request.session.get('order_sid') is None: + return redirect('select_tickets') + + ticket_selection = request.session.get('ticket_selection', {}) + donations = request.session.get('donations', {}) + event = Event.objects.get(active=True) + + total_amount = 0 + ticket_data = [] + items = [] + + ticket_types = TicketType.objects.get_available_ticket_types_for_current_events() + + for ticket_type in ticket_types: + field_name = f'ticket_{ticket_type.id}_quantity' + quantity = ticket_selection.get(field_name, 0) + price = ticket_type.price + subtotal = price * quantity + + if quantity > 0: + total_amount += subtotal + ticket_data.append({ + 'id': ticket_type.id, + 'name': ticket_type.name, + 'description': ticket_type.description, + 'price': price, + 'quantity': quantity, + 'subtotal': subtotal, + }) + items.append({ + "id": ticket_type.name, + "title": ticket_type.name, + "description": ticket_type.description, + "quantity": quantity, + "unit_price": float(price), + }) + + donation_data = [] + + for donation_type, donation_name in [('donation_art', 'Becas de Arte'), ('donation_venue', 'Donaciones a La Sede'), + ('donation_grant', 'Beca Inclusión Radical')]: + donation_amount = donations.get(donation_type, 0) + if donation_amount > 0: + total_amount += donation_amount + donation_data.append({ + 'id': donation_type, + 'name': donation_name, + 'quantity': 1, + 'subtotal': donation_amount, + }) + items.append({ + "id": donation_type, + "title": donation_name, + "quantity": 1, + "unit_price": donation_amount, + }) + + if request.method == 'POST': + total_quantity = sum(item['quantity'] for item in ticket_data) + remaining_event_tickets = event.tickets_remaining() + + if total_quantity > event.max_tickets_per_order: + return HttpResponse('Superaste la cantidad máxima de tickets permitida.', status=401) + + if total_quantity > remaining_event_tickets: + return HttpResponse('No hay suficientes tickets disponibles.', status=400) + + with transaction.atomic(): + order = Order( + first_name=request.user.first_name, + last_name=request.user.last_name, + email=request.user.email, + phone=request.user.profile.phone, + dni=request.user.profile.document_number, + amount=total_amount, + status=Order.OrderStatus.PENDING, + donation_art=donations.get('donation_art', 0), + donation_venue=donations.get('donation_venue', 0), + donation_grant=donations.get('donation_grant', 0), + event=event, + user=request.user, + order_type=Order.OrderType.ONLINE_PURCHASE, + ) + order.save() + + if ticket_types.exists(): + order_tickets = [ + OrderTicket( + order=order, + ticket_type=ticket_type, + quantity=quantity + ) + for ticket_type in ticket_types + if (quantity := ticket_selection.get(f'ticket_{ticket_type.id}_quantity', 0)) > 0 + ] + if order_tickets: + OrderTicket.objects.bulk_create(order_tickets) + + preference_data = { + "items": items, + "payer": { + "name": order.first_name, + "surname": order.last_name, + "email": order.email, + "phone": {"number": order.phone}, + "identification": {"type": "DNI", "number": order.dni}, + }, + "back_urls": { + "success": settings.APP_URL + reverse("checkout_payment_callback", kwargs={'order_key': order.key}), + "failure": settings.APP_URL + reverse("order_summary"), + "pending": settings.APP_URL + reverse("checkout_payment_callback", kwargs={'order_key': order.key}), + }, + "auto_return": "approved", + "statement_descriptor": event.name, + "external_reference": str(order.key), + } + + sdk = mercadopago.SDK(settings.MERCADOPAGO['ACCESS_TOKEN']) + response = sdk.preference().create(preference_data)['response'] + + order.response = response + order.save() + + return HttpResponseRedirect(response['init_point']) + + return render(request, 'checkout/order_summary.html', { + 'ticket_data': ticket_data, + 'donation_data': donation_data, + 'total_amount': total_amount, + }) diff --git a/tickets/views/home.py b/tickets/views/home.py new file mode 100644 index 0000000..14d094c --- /dev/null +++ b/tickets/views/home.py @@ -0,0 +1,28 @@ +from django.http import HttpResponse +from django.template import loader + +from events.models import Event +from tickets.models import Coupon, TicketType + + +def home(request): + context = {} + + event = Event.objects.filter(active=True).first() + + if event: + coupon = Coupon.objects.filter(token=request.GET.get('coupon'), ticket_type__event=event).first() + ticket_types = TicketType.objects.get_available(coupon, event) + context.update({ + 'coupon': coupon, + 'ticket_types': ticket_types + }) + + template = loader.get_template('tickets/home.html') + return HttpResponse(template.render(context, request)) + + +def ping(request): + response = HttpResponse('pong 🏓') + response['x-depreheader'] = 'tu vieja' + return response diff --git a/tickets/views/new_ticket.py b/tickets/views/new_ticket.py new file mode 100644 index 0000000..2891a09 --- /dev/null +++ b/tickets/views/new_ticket.py @@ -0,0 +1,192 @@ +import json +from urllib.parse import urlencode + +from django.contrib.auth.decorators import login_required +from django.contrib.auth.models import User +from django.core.exceptions import ValidationError +from django.core.validators import EmailValidator +from django.db import transaction +from django.http import HttpResponseNotAllowed, HttpResponseForbidden, HttpResponse, HttpResponseBadRequest, \ + JsonResponse +from django.shortcuts import redirect +from django.urls import reverse +from django.utils import timezone + +from tickets.models import NewTicket, NewTicketTransfer +from utils.email import send_mail + + +@login_required +def transfer_ticket(request): + if request.method != 'POST': + return HttpResponseNotAllowed('') + + request_body = json.loads(request.body) + + if 'email' not in request_body or 'ticket_key' not in request_body: + return HttpResponseBadRequest('') + + email = request_body['email'] + ticket_key = request_body['ticket_key'] + + ticket = NewTicket.objects.get(key=ticket_key) + if ticket is None: + return HttpResponseBadRequest('') + if ticket.holder != request.user: + return HttpResponseForbidden('Qué hacés pedazo de gato? Quedaste re escrachado logi') + if not ticket.event.transfer_period(): + return HttpResponseBadRequest('') + + email_validator = EmailValidator() + try: + email_validator(email) + except ValidationError: + return HttpResponseBadRequest('') + + destination_user_exists = User.objects.filter(email=email).exists() + + pending_transfers = NewTicketTransfer.objects.filter(ticket=ticket, status='PENDING').exists() + + if pending_transfers: + return HttpResponseBadRequest('') + + if destination_user_exists is False: + new_ticket_transfer = NewTicketTransfer( + ticket=ticket, + tx_from=request.user, + tx_to_email=email, + status='PENDING' + ) + new_ticket_transfer.save() + # send email + + send_mail( + template_name='new_transfer_no_account', + recipient_list=[email], + context={ + 'ticket_count': 1, + 'destination_email': email, + 'sign_up_link': f"{reverse('account_signup')}?{urlencode({'email': email})}" + } + ) + else: + with transaction.atomic(): + destination_user = User.objects.get(email=email) + destination_user_already_has_ticket = NewTicket.objects.filter(owner=destination_user).exists() + + new_ticket_transfer = NewTicketTransfer( + ticket=ticket, + tx_from=request.user, + tx_to=destination_user, + tx_to_email=destination_user.email, + status='COMPLETED' + ) + + ticket.holder = destination_user + if destination_user_already_has_ticket: + ticket.owner = None + else: + ticket.owner = destination_user + + ticket.volunteer_ranger = None + ticket.volunteer_transmutator = None + ticket.volunteer_umpalumpa = None + + new_ticket_transfer.save() + ticket.save() + + send_mail( + template_name='new_transfer_success', + recipient_list=[email], + context={ + 'ticket_count': 1, + } + ) + + return JsonResponse({'status': 'OK', 'destination_user_exists': destination_user_exists}) + + +@login_required() +def cancel_ticket_transfer(request): + if request.method != 'POST': + return HttpResponseNotAllowed('') + + request_body = json.loads(request.body) + + if 'ticket_key' not in request_body: + return HttpResponseBadRequest('') + + ticket_key = request_body['ticket_key'] + + ticket = NewTicket.objects.get(key=ticket_key) + + if ticket is None: + return HttpResponseBadRequest('') + + if ticket.holder != request.user: + return HttpResponseForbidden('Qué hacés pedazo de gato? Quedaste re escrachado logi') + + if not ticket.event.transfer_period(): + return HttpResponseBadRequest('') + + ticket_transfer = NewTicketTransfer.objects.get(ticket=ticket, status='PENDING', tx_from=request.user) + + if ticket_transfer is None: + return HttpResponseBadRequest('') + + ticket_transfer.status = 'CANCELLED' + ticket_transfer.save() + + return HttpResponse('OK') + + +@login_required() +def assign_ticket(request, ticket_key): + if request.method != 'GET': + return HttpResponseNotAllowed() + + ticket = NewTicket.objects.get(key=ticket_key) + if ticket is None: + return HttpResponseBadRequest() + + if not (ticket.holder == request.user and ticket.owner == None): + return HttpResponseForbidden() + + if not ticket.event.transfer_period(): + return HttpResponseBadRequest('') + + if NewTicket.objects.filter(holder=request.user, owner=request.user).exists(): + return HttpResponseBadRequest + + ticket.owner = request.user + ticket.save() + + return redirect(reverse('my_ticket')) + + +@login_required() +def unassign_ticket(request, ticket_key): + if request.method != 'GET': + return HttpResponseNotAllowed() + + ticket = NewTicket.objects.get(key=ticket_key) + if ticket is None: + return HttpResponseBadRequest() + + if not (ticket.holder == request.user and ticket.owner == request.user): + return HttpResponseForbidden() + + if not ticket.event.transfer_period(): + return HttpResponseBadRequest('') + + if ticket.event.transfers_enabled_until < timezone.now(): + return HttpResponseBadRequest('') + + ticket.volunteer_ranger = None + ticket.volunteer_transmutator = None + ticket.volunteer_umpalumpa = None + ticket.owner = None + + ticket.save() + + return redirect(reverse('transferable_tickets')) diff --git a/tickets/views.py b/tickets/views/order.py similarity index 55% rename from tickets/views.py rename to tickets/views/order.py index 5326c6c..279b4c6 100644 --- a/tickets/views.py +++ b/tickets/views/order.py @@ -1,49 +1,25 @@ -from datetime import datetime +import logging -import mercadopago, logging - -from django.conf import settings -from django.http import HttpResponse, HttpResponseRedirect, HttpResponseBadRequest -from django.template import loader from django.forms import modelformset_factory, BaseModelFormSet +from django.http import HttpResponse, HttpResponseRedirect, JsonResponse, HttpResponseForbidden, HttpResponseBadRequest +from django.template import loader from django.urls import reverse -from django.utils.timezone import now +from django.shortcuts import render, redirect +from django.contrib.auth.decorators import login_required from django.views.decorators.csrf import csrf_exempt +import mercadopago +from django.conf import settings -from .models import Coupon, Order, TicketType, Ticket, TicketTransfer -from .forms import OrderForm, TicketForm, TransferForm from events.models import Event +from tickets.models import Order, TicketType, OrderTicket, Coupon, Ticket +from tickets.forms import OrderForm, CheckoutTicketSelectionForm, CheckoutDonationsForm, TicketForm - -def home(request): - - try: - event = Event.objects.get(active=True) - except Event.DoesNotExist: - event = None - - context = {} - - if event: - coupon = Coupon.objects.filter(token=request.GET.get('coupon'), ticket_type__event=event).first() - - ticket_types = TicketType.objects.get_available(coupon, event) - - context.update({ - 'coupon': coupon, - 'ticket_types': ticket_types - }) - - template = loader.get_template('tickets/home.html') - return HttpResponse(template.render(context, request)) - +from .utils import is_order_valid, _complete_order class BaseTicketFormset(BaseModelFormSet): def __init__(self, *args, **kwargs): super(BaseTicketFormset, self).__init__(*args, **kwargs) self.queryset = Ticket.objects.none() - - def order(request, ticket_type_id): try: event = Event.objects.get(active=True) @@ -51,19 +27,15 @@ def order(request, ticket_type_id): return HttpResponse('Lo sentimos, este link es inválido.', status=404) coupon = Coupon.objects.filter(token=request.GET.get('coupon')).first() - ticket_types = TicketType.objects.get_available(coupon, event) - # we need to iterate over because after slicing we cannot filter for ticket_type in ticket_types: if ticket_type.pk == ticket_type_id: break if ticket_type.pk != ticket_type_id: return HttpResponse('Lo sentimos, este link es inválido.', status=404) - # get available tickets from coupon/type max_tickets = min(ticket_type.available_tickets, coupon.tickets_remaining() if coupon else 5, event.tickets_remaining()) - order_form = OrderForm(request.POST or None) TicketsFormSet = modelformset_factory(Ticket, formset=BaseTicketFormset, form=TicketForm, max_num=max_tickets, validate_max=True, min_num=1, validate_min=True, @@ -79,7 +51,7 @@ def order(request, ticket_type_id): order.coupon = coupon tickets = tickets_formset.save(commit=False) price = ticket_type.price_with_coupon if order.coupon else ticket_type.price - order.amount = len(tickets) * price # + donations + order.amount = len(tickets) * price order.amount += order.donation_art or 0 order.amount += order.donation_grant or 0 order.amount += order.donation_venue or 0 @@ -91,10 +63,6 @@ def order(request, ticket_type_id): return HttpResponseRedirect(redirect_to=reverse('order_detail', kwargs={'order_key': order.key})) - else: - order_form = OrderForm() - tickets_formset = TicketsFormSet() - template = loader.get_template('tickets/order_new.html') context = { 'max_tickets': max_tickets, @@ -106,31 +74,7 @@ def order(request, ticket_type_id): return HttpResponse(template.render(context, request)) - -def is_order_valid(order): - num_tickets = order.ticket_set.count() - - ticket_types = TicketType.objects.get_available(order.coupon, order.ticket_type.event) - - try: - # use the get_available method that annotates the queryset with available_tickets - ticket_type = ticket_types.get(pk=order.ticket_type.pk) - except TicketType.DoesNotExist: - return False - - if ticket_type.available_tickets < num_tickets: - return False - - if order.coupon and order.coupon.tickets_remaining() < num_tickets: - return False - - if ticket_type.event.tickets_remaining() < num_tickets: - return False - return True - - def order_detail(request, order_key): - order = Order.objects.get(key=order_key) logging.info('got order') @@ -159,88 +103,6 @@ def order_detail(request, order_key): return HttpResponse(rendered_template) - -def ticket_detail(request, ticket_key): - - ticket = Ticket.objects.get(key=ticket_key) - - template = loader.get_template('tickets/ticket_detail.html') - context = { - 'ticket': ticket, - 'event': ticket.order.ticket_type.event, - } - - return HttpResponse(template.render(context, request)) - - -def ticket_transfer(request, ticket_key): - ticket = Ticket.objects.select_related('order__ticket_type__event').get(key=ticket_key) - - if ticket.order.ticket_type.event.transfers_enabled_until < now(): - template = loader.get_template('tickets/ticket_transfer_expired.html') - return HttpResponse(template.render({'ticket': ticket}, request)) - - - if request.method == 'POST': - form = TransferForm(request.POST) - if form.is_valid(): - transfer = form.save(commit=False) - transfer.ticket = ticket - transfer.volunteer_ranger = False - transfer.volunteer_transmutator = False - transfer.volunteer_umpalumpa = False - transfer.save() - transfer.send_email() - - return HttpResponseRedirect(reverse('ticket_transfer_confirmation', args=[ticket.key])) - else: - form = TransferForm() - - template = loader.get_template('tickets/ticket_transfer.html') - context = { - 'ticket': ticket, - 'event': ticket.order.ticket_type.event, - 'form': form, - } - - return HttpResponse(template.render(context, request)) - - -def ticket_transfer_confirmation(request, ticket_key): - - ticket = Ticket.objects.get(key=ticket_key) - - template = loader.get_template('tickets/ticket_transfer_confirmation.html') - context = { - 'ticket': ticket, - } - - return HttpResponse(template.render(context, request)) - - -def ticket_transfer_confirmed(request, transfer_key): - - transfer = TicketTransfer.objects.get(key=transfer_key) - - transfer.transfer() - - template = loader.get_template('tickets/ticket_transfer_confirmed.html') - context = { - 'transfer': transfer, - } - - return HttpResponse(template.render(context, request)) - - -def _complete_order(order): - logging.info('completing order') - order.status = Order.OrderStatus.CONFIRMED - logging.info('saving order') - order.save() - logging.info('redirecting') - return HttpResponseRedirect(order.get_resource_url()) - - def free_order_confirmation(request, order_key): order = Order.objects.get(key=order_key) @@ -252,27 +114,23 @@ def free_order_confirmation(request, order_key): return _complete_order(order) - def payment_success(request, order_key): order = Order.objects.get(key=order_key) order.response = request.GET return HttpResponseRedirect(order.get_resource_url()) - def payment_failure(request): return HttpResponse('PAYMENT FAILURE') - def payment_pending(request): return HttpResponse('PAYMENT PENDING') @csrf_exempt def payment_notification(request): - if request.GET['topic'] == 'payment': sdk = mercadopago.SDK(settings.MERCADOPAGO['ACCESS_TOKEN']) payment = sdk.payment().get(request.GET.get('id'))['response'] - + merchant_order = sdk.merchant_order().get(payment['order']['id'])['response'] order = Order.objects.get(id=int(merchant_order['external_reference'])) @@ -290,3 +148,23 @@ def payment_notification(request): return HttpResponse('Notified!') +@login_required +def check_order_status(request, order_key): + order = Order.objects.get(key=order_key) + if order.email != request.user.email: + return HttpResponseForbidden('Forbidden') + return JsonResponse({"status": order.status}) + +@login_required +def checkout_payment_callback(request, order_key): + request.session.pop('ticket_selection', None) + request.session.pop('donations', None) + request.session.pop('order_sid', None) + + order = Order.objects.get(key=order_key) + if order.email != request.user.email: + return HttpResponseForbidden('Forbidden') + + return render(request, 'checkout/payment_callback.html', { + 'order_key': order_key, + }) diff --git a/tickets/views/ticket.py b/tickets/views/ticket.py new file mode 100644 index 0000000..2ac8b33 --- /dev/null +++ b/tickets/views/ticket.py @@ -0,0 +1,69 @@ +from django.http import HttpResponse, HttpResponseRedirect +from django.template import loader +from django.urls import reverse +from django.shortcuts import get_object_or_404 +from tickets.models import Ticket, TicketTransfer +from tickets.forms import TransferForm +from django.utils.timezone import now + +def ticket_detail(request, ticket_key): + ticket = Ticket.objects.get(key=ticket_key) + template = loader.get_template('tickets/ticket_detail.html') + context = { + 'ticket': ticket, + 'event': ticket.order.ticket_type.event, + } + return HttpResponse(template.render(context, request)) + +def ticket_transfer(request, ticket_key): + ticket = Ticket.objects.select_related('order__ticket_type__event').get(key=ticket_key) + + if ticket.order.ticket_type.event.transfers_enabled_until < now(): + template = loader.get_template('tickets/ticket_transfer_expired.html') + return HttpResponse(template.render({'ticket': ticket}, request)) + + if request.method == 'POST': + form = TransferForm(request.POST) + if form.is_valid(): + transfer = form.save(commit=False) + transfer.ticket = ticket + transfer.volunteer_ranger = False + transfer.volunteer_transmutator = False + transfer.volunteer_umpalumpa = False + transfer.save() + transfer.send_email() + + return HttpResponseRedirect(reverse('ticket_transfer_confirmation', args=[ticket.key])) + else: + form = TransferForm() + + template = loader.get_template('tickets/ticket_transfer.html') + context = { + 'ticket': ticket, + 'event': ticket.order.ticket_type.event, + 'form': form, + } + + return HttpResponse(template.render(context, request)) + +def ticket_transfer_confirmation(request, ticket_key): + ticket = Ticket.objects.get(key=ticket_key) + + template = loader.get_template('tickets/ticket_transfer_confirmation.html') + context = { + 'ticket': ticket, + } + + return HttpResponse(template.render(context, request)) + +def ticket_transfer_confirmed(request, transfer_key): + transfer = TicketTransfer.objects.get(key=transfer_key) + + transfer.transfer() + + template = loader.get_template('tickets/ticket_transfer_confirmed.html') + context = { + 'transfer': transfer, + } + + return HttpResponse(template.render(context, request)) diff --git a/tickets/views/utils.py b/tickets/views/utils.py new file mode 100644 index 0000000..2023780 --- /dev/null +++ b/tickets/views/utils.py @@ -0,0 +1,36 @@ +import logging + +from django.db.models import Sum +from django.http import HttpResponseRedirect + +from events.models import Event +from tickets.models import TicketType, Order + + +def is_order_valid(order): + num_tickets = order.ticket_set.count() + ticket_types = TicketType.objects.get_available(order.coupon, order.ticket_type.event) + + try: + ticket_type = ticket_types.get(pk=order.ticket_type.pk) + except TicketType.DoesNotExist: + return False + + if ticket_type.available_tickets < num_tickets: + return False + + if order.coupon and order.coupon.tickets_remaining() < num_tickets: + return False + + if ticket_type.event.tickets_remaining() < num_tickets: + return False + return True + + +def _complete_order(order): + logging.info('completing order') + order.status = Order.OrderStatus.CONFIRMED + logging.info('saving order') + order.save() + logging.info('redirecting') + return HttpResponseRedirect(order.get_resource_url()) diff --git a/tickets/views/webhooks.py b/tickets/views/webhooks.py new file mode 100644 index 0000000..fe399bb --- /dev/null +++ b/tickets/views/webhooks.py @@ -0,0 +1,130 @@ +import hashlib +import hmac +import json +import logging +import urllib + +import mercadopago +from django.conf import settings +from django.http import JsonResponse +from django.views.decorators.csrf import csrf_exempt + +from tickets.models import Order, OrderTicket + + +@csrf_exempt +def mercadopago_webhook(request): + if request.method == 'POST': + try: + if not verify_hmac_request(request): + logging.info("HMAC verification failed") + return JsonResponse({"status": "forbidden"}, status=403) + + payload = json.loads(request.body) + logging.info("Webhook payload:") + logging.info(payload) + + if payload['action'] == 'payment.created': + handle_payment_created(payload) + + return JsonResponse({"status": "success"}, status=200) + + except AttributeError as e: + logging.error(f"Attribute error: {str(e)}") + return JsonResponse({"status": "error", "message": "Internal attribute error"}, status=500) + + except KeyError as e: + logging.error(f"Key error: {str(e)}") + return JsonResponse({"status": "error", "message": f"Missing key: {str(e)}"}, status=400) + + except Exception as e: + logging.error(f"Unexpected error: {str(e)}") + return JsonResponse({"status": "error", "message": "An unexpected error occurred"}, status=500) + else: + return JsonResponse({"status": "method not allowed"}, status=405) + + +def verify_hmac_request(request): + try: + x_signature = request.headers.get("x-signature") + x_request_id = request.headers.get("x-request-id") + query_params = urllib.parse.parse_qs(request.GET.urlencode()) + data_id = query_params.get("data.id", [""])[0] + + ts, hash_value = parse_signature(x_signature) + + if verify_hmac(data_id, x_request_id, ts, hash_value): + logging.info("HMAC verification passed") + return True + return False + + except Exception as e: + logging.error(f"Error during HMAC verification: {str(e)}") + raise e + + +def parse_signature(x_signature): + ts = None + hash_value = None + parts = x_signature.split(",") + + for part in parts: + key_value = part.split("=", 1) + if len(key_value) == 2: + key = key_value[0].strip() + value = key_value[1].strip() + if key == "ts": + ts = value + elif key == "v1": + hash_value = value + + return ts, hash_value + + +def verify_hmac(data_id, x_request_id, ts, hash_value): + secret = settings.MERCADOPAGO['WEBHOOK_SECRET'] + manifest = f"id:{data_id};request-id:{x_request_id};ts:{ts};" + hmac_obj = hmac.new(secret.encode(), msg=manifest.encode(), digestmod=hashlib.sha256) + sha = hmac_obj.hexdigest() + return sha == hash_value + + +def handle_payment_created(payload): + try: + sdk = mercadopago.SDK(settings.MERCADOPAGO['ACCESS_TOKEN']) + payment = sdk.payment().get(payload['data']['id'])['response'] + logging.info(payment) + + if payment['status'] == 'approved': + order_approved(payment) + + except KeyError as e: + logging.error(f"Missing key in payload: {str(e)}") + raise e + + except Exception as e: + logging.error(f"Error handling payment creation: {str(e)}") + raise e + + +def order_approved(payment): + try: + order = Order.objects.get(key=payment['external_reference']) + if order.status != Order.OrderStatus.PENDING: + logging.info(f"Order {order.key} already confirmed") + return + + order.status = Order.OrderStatus.PROCESSING + order.save() + + except Order.DoesNotExist as e: + logging.error(f"Order not found: {str(e)}") + raise e + + except AttributeError as e: + logging.error(f"Attribute error in processing order: {str(e)}") + raise e + + except Exception as e: + logging.error(f"Error processing order: {str(e)}") + raise e diff --git a/update_zappa_envs.py b/update_zappa_envs.py new file mode 100644 index 0000000..fe8b488 --- /dev/null +++ b/update_zappa_envs.py @@ -0,0 +1,27 @@ +import json +import sys +from dotenv import dotenv_values + +# Get the environment (dev or prod) from command line arguments +if len(sys.argv) != 2 or sys.argv[1] not in ['dev', 'prod']: + print("Usage: python update_zappa_envs.py [dev|prod]") + sys.exit(1) + +environment = sys.argv[1] + +# Load the appropriate .env file based on the environment +env_file = f"env.{environment}" +env_vars = dotenv_values(env_file) + +# Load zappa_settings.json +with open("zappa_settings.json", "r") as f: + zappa_settings = json.load(f) + +# Update the environment_variables section for the specific environment in zappa_settings +zappa_settings[environment]["aws_environment_variables"] = env_vars + +# Save back to zappa_settings.json +with open("zappa_settings.json", "w") as f: + json.dump(zappa_settings, f, indent=4) + +print(f"Environment variables from {env_file} have been added to zappa_settings.json") diff --git a/user_profile/__init__.py b/user_profile/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/user_profile/admin.py b/user_profile/admin.py new file mode 100644 index 0000000..c83c0ff --- /dev/null +++ b/user_profile/admin.py @@ -0,0 +1,42 @@ +from django.contrib import admin +from django.contrib.auth.admin import UserAdmin +from django.contrib.auth.models import User + +from .models import Profile + + +User.__str__ = lambda self: f'{self.first_name} {self.last_name} ({self.email})' + + +class ProfileInline(admin.StackedInline): + model = Profile + can_delete = False + verbose_name_plural = 'profile' + + +# Crea una nueva clase que extienda de LibraryUserAdmin +class CustomUserAdmin(UserAdmin): + inlines = (ProfileInline,) + list_display = ( + 'email', 'is_staff', 'is_superuser', 'first_name', 'last_name', 'get_phone', 'get_document_type', + 'get_document_number') + + def get_phone(self, instance): + return instance.profile.phone + + get_phone.short_description = 'Phone' + + def get_document_type(self, instance): + return instance.profile.document_type + + get_document_type.short_description = 'Document Type' + + def get_document_number(self, instance): + return instance.profile.document_number + + get_document_number.short_description = 'Document Number' + + +# Quitar el registro original y registrar el nuevo UserAdmin +admin.site.unregister(User) +admin.site.register(User, CustomUserAdmin) \ No newline at end of file diff --git a/user_profile/apps.py b/user_profile/apps.py new file mode 100644 index 0000000..e05b4fd --- /dev/null +++ b/user_profile/apps.py @@ -0,0 +1,9 @@ +from django.apps import AppConfig + + +class UserProfileConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'user_profile' + + def ready(self): + import user_profile.signals # noqa diff --git a/user_profile/forms.py b/user_profile/forms.py new file mode 100644 index 0000000..62fdbec --- /dev/null +++ b/user_profile/forms.py @@ -0,0 +1,148 @@ +from django import forms +from django.conf import settings + +from twilio.rest import Client + +from .models import Profile +from tickets.models import NewTicket + + +class ProfileStep1Form(forms.ModelForm): + first_name = forms.CharField(max_length=30, required=True) + last_name = forms.CharField(max_length=30, required=True) + + class Meta: + model = Profile + fields = ["document_type", "document_number"] + + def __init__(self, *args, **kwargs): + user = kwargs.pop("user", None) + super(ProfileStep1Form, self).__init__(*args, **kwargs) + if user: + self.fields["first_name"].initial = user.first_name + self.fields["last_name"].initial = user.last_name + + def clean_document_number(self): + document_number = self.cleaned_data.get("document_number", "") + # Remove periods from the document_number + cleaned_document_number = document_number.replace(".", "") + return cleaned_document_number + + def clean(self): + cleaned_data = super(ProfileStep1Form, self).clean() + document_type = cleaned_data.get("document_type") + document_number = self.clean_document_number() + + # Check for duplicate document number and type + if ( + document_number and + Profile.objects.filter( + document_type=document_type, document_number=document_number + ) + .exclude(user=self.instance.user) + .exists() + ): + raise forms.ValidationError( + "Otro usuario ya tiene este tipo de documento y número." + ) + + return cleaned_data + + def save(self, commit=True): + # Create a profile instance, but don't save it yet + profile = super(ProfileStep1Form, self).save(commit=False) + + # Clean document_number before saving + profile.document_number = self.clean_document_number() + + # Update the user's first and last name + user = profile.user + user.first_name = self.cleaned_data["first_name"] + user.last_name = self.cleaned_data["last_name"] + + # If we are saving, also update and save the profile instance + if commit: + user.save() + profile.save() + + return profile + + +class ProfileStep2Form(forms.ModelForm): + code = forms.CharField(max_length=6, required=False, label="Código de verificación") + full_phone_number = forms.CharField(widget=forms.HiddenInput(), required=False) + + class Meta: + model = Profile + fields = ["phone"] + + def __init__(self, *args, **kwargs): + code_sent = kwargs.pop("code_sent", False) + super(ProfileStep2Form, self).__init__(*args, **kwargs) + + if code_sent: + self.fields["phone"].required = ( + False # Make phone non-required if code is sent + ) + self.fields["phone"].disabled = ( + True # Disable the phone field if code is sent + ) + + def clean_phone(self): + # Use the full_phone_number if provided + full_phone_number = self.cleaned_data.get("full_phone_number") + if full_phone_number: + return full_phone_number + return self.cleaned_data["phone"] + + def clean(self): + cleaned_data = super(ProfileStep2Form, self).clean() + phone = cleaned_data.get("phone") + + # Check for duplicate phone number + if ( + Profile.objects.filter(phone=phone) + .exclude(user=self.instance.user) + .exists() + ): + raise forms.ValidationError( + "Otro usuario ya tiene este número de teléfono." + ) + + return cleaned_data + + def send_verification_code(self): + phone = self.cleaned_data["phone"] + + if settings.ENV == "local" or settings.MOCK_PHONE_VERIFICATION: + return "123456" + + client = Client(settings.TWILIO_ACCOUNT_SID, settings.TWILIO_AUTH_TOKEN) + verification = client.verify.v2.services( + settings.TWILIO_VERIFY_SERVICE_SID + ).verifications.create(to=phone, channel="sms") + return verification.sid + + def verify_code(self): + phone = self.cleaned_data["phone"] + code = self.cleaned_data.get("code") + + if settings.ENV == "local" or settings.MOCK_PHONE_VERIFICATION: + return True + + client = Client(settings.TWILIO_ACCOUNT_SID, settings.TWILIO_AUTH_TOKEN) + verification_check = client.verify.v2.services( + settings.TWILIO_VERIFY_SERVICE_SID + ).verification_checks.create(to=phone, code=code) + return verification_check.status == "approved" + + +class VolunteeringForm(forms.ModelForm): + + volunteer_ranger = forms.BooleanField(label='Ranger', required=False) + volunteer_transmutator = forms.BooleanField(label='Transmutadores', required=False) + volunteer_umpalumpa = forms.BooleanField(label='CAOS (Desarme de la Ciudad)', required=False) + + class Meta: + model = NewTicket + fields = ["volunteer_ranger", "volunteer_transmutator", "volunteer_umpalumpa"] diff --git a/user_profile/groups.py b/user_profile/groups.py new file mode 100644 index 0000000..5c9184d --- /dev/null +++ b/user_profile/groups.py @@ -0,0 +1,12 @@ +GROUPS_PERMISSIONS = { + 'Caja': [ + {'app_label': 'tickets', 'codename': 'can_sell_tickets'}, + ], + 'Admin Voluntarios': [ + {'app_label': 'tickets', 'codename': 'admin_volunteers'}, + {'app_label': 'tickets', 'codename': 'add_directtickettemplate'}, + {'app_label': 'tickets', 'codename': 'change_directtickettemplate'}, + {'app_label': 'tickets', 'codename': 'delete_directtickettemplate'}, + {'app_label': 'tickets', 'codename': 'view_directtickettemplate'}, + ], +} \ No newline at end of file diff --git a/user_profile/migrations/0001_initial.py b/user_profile/migrations/0001_initial.py new file mode 100644 index 0000000..31e69ef --- /dev/null +++ b/user_profile/migrations/0001_initial.py @@ -0,0 +1,34 @@ +# Generated by Django 4.2.15 on 2024-10-27 18:47 + +from django.conf import settings +import django.core.validators +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("tickets", "0039_delete_profile"), + ] + + state_operations = [ + migrations.CreateModel( + name='Profile', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('document_type', models.CharField(choices=[('DNI', 'DNI'), ('PASSPORT', 'Passport'), ('OTHER', 'Other')], default='DNI', max_length=10)), + ('document_number', models.CharField(max_length=50)), + ('phone', models.CharField(max_length=15, validators=[django.core.validators.RegexValidator('^\\+?1?\\d{9,15}$')])), + ('profile_completion', models.CharField(choices=[('NONE', 'None'), ('INITIAL_STEP', 'Initial Step'), ('COMPLETE', 'Complete')], default='NONE', max_length=15)), + ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + ), + ] + + operations = [ + migrations.SeparateDatabaseAndState(state_operations=state_operations) + ] diff --git a/user_profile/migrations/__init__.py b/user_profile/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/user_profile/models.py b/user_profile/models.py new file mode 100644 index 0000000..9c46e42 --- /dev/null +++ b/user_profile/models.py @@ -0,0 +1,34 @@ +from django.db import models +from django.contrib.auth.models import User +from django.core.validators import RegexValidator + + +class Profile(models.Model): + DNI = 'DNI' + PASSPORT = 'PASSPORT' + OTHER = 'OTHER' + + DOCUMENT_TYPE_CHOICES = [ + (DNI, 'DNI'), + (PASSPORT, 'Passport'), + (OTHER, 'Other'), + ] + + NONE = 'NONE' + INITIAL_STEP = 'INITIAL_STEP' + COMPLETE = 'COMPLETE' + + PROFILE_COMPLETION_CHOICES = [ + (NONE, 'None'), + (INITIAL_STEP, 'Initial Step'), + (COMPLETE, 'Complete'), + ] + + user = models.OneToOneField(User, on_delete=models.CASCADE, related_name='profile') + document_type = models.CharField(max_length=10, choices=DOCUMENT_TYPE_CHOICES, default=DNI) + document_number = models.CharField(max_length=50) + phone = models.CharField(max_length=15, validators=[RegexValidator(r'^\+?1?\d{9,15}$')]) + profile_completion = models.CharField(max_length=15, choices=PROFILE_COMPLETION_CHOICES, default=NONE) + + def __str__(self): + return self.user.username diff --git a/user_profile/signals.py b/user_profile/signals.py new file mode 100644 index 0000000..3b51171 --- /dev/null +++ b/user_profile/signals.py @@ -0,0 +1,53 @@ +import uuid + +from django.contrib.auth.models import Group, Permission +from django.contrib.auth.models import User +from django.db.models.signals import post_save, pre_save, post_migrate +from django.dispatch import receiver + +from .models import Profile +from .groups import GROUPS_PERMISSIONS + + +@receiver(pre_save, sender=User) +def set_username(sender, instance, **kwargs): + if not instance.username: + instance.username = instance.email + + +@receiver(post_save, sender=User) +def create_user_profile(sender, instance, created, **kwargs): + if created and not instance.is_staff and not hasattr(instance, 'profile'): + Profile.objects.create(user=instance) + + +@receiver(post_save, sender=User) +def save_user_profile(sender, instance, **kwargs): + if not instance.is_staff: + instance.profile.save() + +@receiver(post_migrate) +def create_user_groups(sender, **kwargs): + """ + Creates user groups and assigns permissions after migrations. + """ + + for group_name, perms in GROUPS_PERMISSIONS.items(): + group, created = Group.objects.get_or_create(name=group_name) + if created: + print(f"Created group: {group_name}") + + permission_objects = [] + for perm in perms: + try: + permission = Permission.objects.get( + codename=perm['codename'], + content_type__app_label=perm['app_label'] + ) + permission_objects.append(permission) + except Permission.DoesNotExist: + print(f"Permission {perm['codename']} in app {perm['app_label']} does not exist.") + + # Assign permissions to the group + group.permissions.set(permission_objects) + group.save() \ No newline at end of file diff --git a/user_profile/templates/account/account_change_password.html b/user_profile/templates/account/account_change_password.html new file mode 100644 index 0000000..3d4e1db --- /dev/null +++ b/user_profile/templates/account/account_change_password.html @@ -0,0 +1,55 @@ +{% extends "../tickets/base.html" %} +{% load static %} + +{% block content %} +
+
+

Cambiar la contraseña

+ + + + + + {% if form.errors %} + + {% endif %} +
+ {% csrf_token %} + +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+
    +
  • Su contraseña no puede ser similar a otros componentes de su información personal.
  • +
  • Su contraseña debe contener por lo menos 8 caracteres.
  • +
  • Su contraseña no puede ser una contraseña usada muy comúnmente.
  • +
  • Su contraseña no puede estar formada exclusivamente por números.
  • +
+
+ + +
+
+
+{% endblock %} diff --git a/user_profile/templates/account/base.html b/user_profile/templates/account/base.html new file mode 100644 index 0000000..5e3c88d --- /dev/null +++ b/user_profile/templates/account/base.html @@ -0,0 +1,9 @@ +{% extends '../tickets/barbu_base.html' %} +{% block full_content %} + +{% endblock %} \ No newline at end of file diff --git a/user_profile/templates/account/complete_profile_step1.html b/user_profile/templates/account/complete_profile_step1.html new file mode 100644 index 0000000..aeeb562 --- /dev/null +++ b/user_profile/templates/account/complete_profile_step1.html @@ -0,0 +1,96 @@ +{% extends "./base.html" %} +{% load static %} +{% block innercontent %} +
+
+
+
Paso 1 de 3
+

Completa tu perfil

+
+ {% csrf_token %} +
+ {% if form.non_field_errors %} +
+ {% for error in form.non_field_errors %}{{ error }}{% endfor %} +
+ {% endif %} + + + + + + + + + +
+
+
+
+
+ +{% endblock %} diff --git a/user_profile/templates/account/complete_profile_step2.html b/user_profile/templates/account/complete_profile_step2.html new file mode 100644 index 0000000..0b58411 --- /dev/null +++ b/user_profile/templates/account/complete_profile_step2.html @@ -0,0 +1,127 @@ +{% extends "./base.html" %} +{% load static %} +{% block extrahead %} + {{ block.super }} + + +{% endblock extrahead %} +{% block innercontent %} +
+
+
+
Paso 2 de 3
+

Verificá tu teléfono

+
+ {% csrf_token %} +
+ {% if form.non_field_errors %} + + {% endif %} + + + {% if code_sent %} + + + {% if error_message %}
{{ error_message }}
{% endif %} + + + + + {% else %} + + + + {% endif %} +
+
+
+
+
+ +{% endblock %} diff --git a/user_profile/templates/account/email/base.html b/user_profile/templates/account/email/base.html new file mode 100644 index 0000000..f08168d --- /dev/null +++ b/user_profile/templates/account/email/base.html @@ -0,0 +1,72 @@ + +{% load inlinecss %} +{% load static %} + +{% block subject %}Fuego Austral{% endblock %} +{% block html %} +{% inlinecss "css/emails.css" %} + + + + + + + + + + + + + + + +{% endinlinecss %} +{% endblock %} diff --git a/user_profile/templates/account/email/email_confirmation_signup_message.html b/user_profile/templates/account/email/email_confirmation_signup_message.html new file mode 100644 index 0000000..a2ded57 --- /dev/null +++ b/user_profile/templates/account/email/email_confirmation_signup_message.html @@ -0,0 +1,13 @@ +{% extends './base.html' %} + +{% block title %} + Confirmar email +{% endblock %} + +{% block content %} + Hola +

Hace clic aca para confirmar tu email

+ +

Si no ves el link copia y pega el siguiente link en tu navegador:

+ {{ activate_url }} +{% endblock %} \ No newline at end of file diff --git a/user_profile/templates/account/email/email_confirmation_signup_subject.txt b/user_profile/templates/account/email/email_confirmation_signup_subject.txt new file mode 100644 index 0000000..4c43609 --- /dev/null +++ b/user_profile/templates/account/email/email_confirmation_signup_subject.txt @@ -0,0 +1 @@ +📭 Necesitamos confirmar tu dirrecion de email 📭 \ No newline at end of file diff --git a/user_profile/templates/account/email/password_reset_key_message.html b/user_profile/templates/account/email/password_reset_key_message.html new file mode 100644 index 0000000..973e2b4 --- /dev/null +++ b/user_profile/templates/account/email/password_reset_key_message.html @@ -0,0 +1,15 @@ +{% extends './base.html' %} + +{% block title %} + Confirmar email +{% endblock %} + +{% block content %} + Hola, este es el mail para resetear la password. + + +

Hace clic aca

+ +

Si no ves el linkl copia y pega el siguiente link en tu navegador:

+ {{ password_reset_url }} +{% endblock %} \ No newline at end of file diff --git a/user_profile/templates/account/email/password_reset_key_subject.txt b/user_profile/templates/account/email/password_reset_key_subject.txt new file mode 100644 index 0000000..6e0ec59 --- /dev/null +++ b/user_profile/templates/account/email/password_reset_key_subject.txt @@ -0,0 +1 @@ +🔑 Email de reseteo de password 🔑 \ No newline at end of file diff --git a/user_profile/templates/account/login.html b/user_profile/templates/account/login.html new file mode 100644 index 0000000..9fa9d66 --- /dev/null +++ b/user_profile/templates/account/login.html @@ -0,0 +1,62 @@ +{% extends "./base.html" %} +{% load socialaccount %} +{% load static %} +{% block innercontent %} +
+
+
+
+

Iniciar sesión

+
+ No tenés cuenta? + Registrate +
+ + Google + Inicia sesión con Google + +
+
+ o +
+
+
+ {% csrf_token %} +
+ {% if form.errors %} + + {% endif %} + + + {{ form.remember_me }} + {{ form.remember_me.label_tag }} + +
+ +
+
+
+
+
+
+
+{% endblock %} diff --git a/user_profile/templates/account/password_reset.html b/user_profile/templates/account/password_reset.html new file mode 100644 index 0000000..f0c52bd --- /dev/null +++ b/user_profile/templates/account/password_reset.html @@ -0,0 +1,47 @@ +{% extends "./base.html" %} +{% load static %} + +{% block innercontent %} +
+
+
+
+

Restablecer Contraseña

+
+ Ingresa tu dirección de correo electrónico para recibir un enlace de restablecimiento de contraseña. +
+
+ {% csrf_token %} +
+ {% if form.errors %} + + {% endif %} + + +
+ +
+
+
+
+
+
+
+{% endblock %} diff --git a/user_profile/templates/account/password_reset_done.html b/user_profile/templates/account/password_reset_done.html new file mode 100644 index 0000000..8b060ec --- /dev/null +++ b/user_profile/templates/account/password_reset_done.html @@ -0,0 +1,34 @@ +{% extends "./base.html" %} +{% load static %} +{% block innercontent %} +
+
+
+
+

Correo de Recuperación Enviado

+
+ Se ha enviado un enlace para restablecer tu contraseña a la dirección de correo electrónico que proporcionaste. Por favor, revisa tu bandeja de entrada y sigue las instrucciones del correo para restablecer tu contraseña. +
+ +
+
+
+
+
+
+{% endblock %} +{% block content %} +
+
+

Correo de Restablecimiento Enviado

+ + +
+
+{% endblock %} diff --git a/user_profile/templates/account/password_reset_from_key.html b/user_profile/templates/account/password_reset_from_key.html new file mode 100644 index 0000000..b6dd768 --- /dev/null +++ b/user_profile/templates/account/password_reset_from_key.html @@ -0,0 +1,51 @@ +{% extends "./base.html" %} +{% load static %} +{% block innercontent %} +
+
+
+
+

Restablecer Contraseña

+
+ Ingresa tu dirección de correo electrónico para recibir un enlace de restablecimiento de contraseña. +
+
+ {% csrf_token %} +
+ {% if form.errors %} + + {% endif %} + + + +
+ +
+
+
+
+
+{% endblock %} diff --git a/user_profile/templates/account/password_reset_from_key_done.html b/user_profile/templates/account/password_reset_from_key_done.html new file mode 100644 index 0000000..8af35ad --- /dev/null +++ b/user_profile/templates/account/password_reset_from_key_done.html @@ -0,0 +1,19 @@ +{% extends "./base.html" %} +{% load static %} +{% block innercontent %} +
+
+
+
+

Contraseña Restablecida

+
+ Tu contraseña ha sido restablecida exitosamente. Ahora podés iniciar sesión con tu nueva contraseña. +
+ +
+
+
+
+{% endblock %} diff --git a/user_profile/templates/account/profile_congrats.html b/user_profile/templates/account/profile_congrats.html new file mode 100644 index 0000000..eada272 --- /dev/null +++ b/user_profile/templates/account/profile_congrats.html @@ -0,0 +1,15 @@ +{% extends "./base.html" %} +{% load static %} +{% block innercontent %} +
+
+
+

¡Gracias por completar tu perfil! ❤️

+
Con tu usuario podrás acceder a tus bonos, y mucho más.
+ +
+
+
+{% endblock %} diff --git a/user_profile/templates/account/profile_congrats_with_tickets.html b/user_profile/templates/account/profile_congrats_with_tickets.html new file mode 100644 index 0000000..24ef123 --- /dev/null +++ b/user_profile/templates/account/profile_congrats_with_tickets.html @@ -0,0 +1,19 @@ +{% extends "./base.html" %} +{% load static %} +{% block innercontent %} +
+
+
+
+

¡Se te han asignado nuevos bonos de Fuego Austral! 🎟️️

+
+ Para ver tus bonos, entra a mis bonos. +
+ +
+
+
+
+{% endblock %} diff --git a/user_profile/templates/account/signup.html b/user_profile/templates/account/signup.html new file mode 100644 index 0000000..15bd0ea --- /dev/null +++ b/user_profile/templates/account/signup.html @@ -0,0 +1,85 @@ +{% extends "./base.html" %} +{% load socialaccount %} +{% load static %} +{% block innercontent %} +
+
+
+
+

Crear cuenta

+
+ Ya tenés cuenta? + Inicia sesión +
+ + Google + Registrate con Google + +
+
+ o +
+
+
+ {% csrf_token %} +
+ {% if form.errors %} + + {% endif %} + + + + {{ redirect_field }} + +
+
+
+
+
+
+ +{% endblock %} diff --git a/user_profile/templates/account/verification_congrats.html b/user_profile/templates/account/verification_congrats.html new file mode 100644 index 0000000..0fca3a2 --- /dev/null +++ b/user_profile/templates/account/verification_congrats.html @@ -0,0 +1,19 @@ +{% extends "./base.html" %} +{% load static %} +{% block innercontent %} +
+
+
+
+

Cuenta verificada 🎉

+
+ Ahora puedes iniciar sesión en tu cuenta para continuar el proceso de registro. +
+ +
+
+
+
+{% endblock %} diff --git a/user_profile/templates/account/verification_sent.html b/user_profile/templates/account/verification_sent.html new file mode 100644 index 0000000..28ca29d --- /dev/null +++ b/user_profile/templates/account/verification_sent.html @@ -0,0 +1,18 @@ +{% extends "./base.html" %} +{% load static %} +{% block innercontent %} +
+
+
+

Verifica tu Email

+
+ Enviamos un correo para su verificación.
+ Seguí las instrucciones para finalizar el proceso de registro.

+ + Si no ves el correo en tu bandeja de entrada,
+ comprueba tu carpeta de spam / correo no deseado. +
+
+
+
+{% endblock %} diff --git a/user_profile/templates/mi_fuego/my_tickets/index.html b/user_profile/templates/mi_fuego/my_tickets/index.html new file mode 100644 index 0000000..743d794 --- /dev/null +++ b/user_profile/templates/mi_fuego/my_tickets/index.html @@ -0,0 +1,49 @@ +{% extends '../../tickets/barbu_base.html' %} +{% block page_title %} + Mi Perfil +{% endblock page_title %} +{% block menu %} + + +{% endblock menu %} +{% block alerts %} + {% if has_unassigned_tickets or has_transfer_pending %} + {% if event.transfer_period %} +
+
+
+ Tenés + {% if has_unassigned_tickets %} + bonos sin asignar, + {% if has_transfer_pending %}y{% endif %} + {% endif %} + {% if has_transfer_pending %} + {% if not has_unassigned_tickets %}bonos{% endif %} + que todavía no fueron aceptados, + {% endif %} + acordate que + tenés tiempo para transferirlos hasta el + {{ event.transfers_enabled_until|date:'d/m' }} + . +
+
+
+ {% endif %} +{% endif %} +{% endblock alerts %} +{% block submenu %} + + +{% endblock submenu %} diff --git a/user_profile/templates/mi_fuego/my_tickets/my_ticket.html b/user_profile/templates/mi_fuego/my_tickets/my_ticket.html new file mode 100644 index 0000000..9c4d77a --- /dev/null +++ b/user_profile/templates/mi_fuego/my_tickets/my_ticket.html @@ -0,0 +1,80 @@ +{% extends 'mi_fuego/my_tickets/index.html' %} +{% load static %} +{% block actions %} +{% if my_ticket and event.transfers_enabled_until >= now %} + +{% endif %} + Comprar Bono +{% endblock actions %} +{% block truecontent %} + {% if my_ticket %} +
+ {% if not is_volunteer %} +
+
+
+ Fuego Austral es un encuentro participativo y todos tenemos un talento para aportar a nuestra ciudad temporal. Te invitamos a sumarte al área en la que sabés que podés dar lo mejor de vos. +
+ +
+
+ {% endif %} +
+
{{ event.name }}
+
+
+
+
Nombre(s)
+
+ {{ user.first_name }} +
+
Apellido(s)
+
+ {{ user.last_name }} +
+
{{ user.profile.document_type }}
+
+ {{ user.profile.document_number }} +
+
+
+
+
+ QR Code +
+ {{ my_ticket.ticket_type }} +
+
+ +
+
+ {% else %} +
+
+
+

No tenés bono

+ Comprar Bono +
+
+
+ {% endif %} + {% if my_ticket %} + + {% endif %} +{% endblock truecontent %} diff --git a/user_profile/templates/mi_fuego/my_tickets/transferable_tickets.html b/user_profile/templates/mi_fuego/my_tickets/transferable_tickets.html new file mode 100644 index 0000000..e4c4c3a --- /dev/null +++ b/user_profile/templates/mi_fuego/my_tickets/transferable_tickets.html @@ -0,0 +1,275 @@ +{% extends 'mi_fuego/my_tickets/index.html' %} +{% load static %} +{% block truecontent %} + {% if not tickets_dto and not transferred_dto %} +
+
+
+

No tenés bonos transferibles

+ Comprar Bono +
+
+
+ {% else %} +
+
+ + + + +
+
+ {% for ticket in tickets_dto %} +
+
{{ event.name }}
+
+ Fuego Austral + {{ ticket.ticket_type }} +
+ {% if ticket.is_transfer_pending %} + Invitacion pendiente a +
+ {{ ticket.transferring_to }}
+ {% if event.transfer_period %} + Cancelar + invitación + {% endif %} + {% else %} + {% if event.transfer_period %} + + {% if not my_ticket %} + Asignarmelo + {% endif %} + {% endif %} + {% endif %} +
+
+
+ {% endfor %} + {% for ticket in transferred_dto %} +
+
{{ event.name }}
+
+ Fuego Austral + {{ ticket.ticket_type }} +
+ Transferencia completa a +
+ {{ ticket.tx_to_email }}
+
+
+
+ {% endfor %} +
+
+ + {% endif %} + + +{% endblock truecontent %} diff --git a/user_profile/templates/mi_fuego/my_tickets/volunteering.html b/user_profile/templates/mi_fuego/my_tickets/volunteering.html new file mode 100644 index 0000000..b2091c9 --- /dev/null +++ b/user_profile/templates/mi_fuego/my_tickets/volunteering.html @@ -0,0 +1,83 @@ +{% extends 'mi_fuego/my_tickets/index.html' %} +{% load static %} +{% block innercontent %} +
+
+
+

Quiero ser Voluntario

+
Seleccioná tu(s) rol(es)
+ {% if event.volunteer_period %} + {% if event.volunteers_enabled_until %} +
+ Tenés tiempo para postularte como voluntario hasta el {{ event.volunteers_enabled_until|date:'d/m' }}. +
+ {% endif %} + {% else %} +
+ Finalizó el plazo para postularse como voluntario. +
+ {% endif %} +
+
+ {% csrf_token %} +
+
+
{{ form.volunteer_ranger }}
+
+ +
+ Los rangers son aquellos miembros de Fuego + Austral que ayudan a sostener a la + comunidad, se ocupan de cuidar a la gente y + al lugar, y son los encargados de mantener + en pie los 10 principios. +
+
+ Importante: es condición obligatoria haber + asistido al menos a un evento para ser Ranger. +
+
+
+
+
+
+
{{ form.volunteer_transmutator }}
+
+ +
+ Los transmutadores son el equipo encargado + de recibir a cada persona que ingresa y + darle la bienvenida oficial a nuestra + querida ciudad temporal. +
+
+
+
+
+
+
{{ form.volunteer_umpalumpa }}
+
+ +
+ Son los encargados de, terminado el evento, + desarmar y asegurarse de que no quede ningún + rastro. Son una pieza clave, son creativos, + resolutivos y comprometidos. +
+
+
+
+ {% if event.volunteer_period %} +
+
+ Cancelar + +
+
+ {% endif %} +
+
+
+
+
+{% endblock innercontent %} diff --git a/user_profile/tests.py b/user_profile/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/user_profile/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/user_profile/urls.py b/user_profile/urls.py new file mode 100644 index 0000000..52811ec --- /dev/null +++ b/user_profile/urls.py @@ -0,0 +1,26 @@ +from django.urls import path + +from .views import ( + complete_profile, + verification_congrats, + profile_congrats, + my_fire_view, + my_ticket_view, + transferable_tickets_view, + volunteering, +) + +urlpatterns = [ + # Profile related paths + path("complete-profile/", complete_profile, name="complete_profile"), + path("verification-congrats/", verification_congrats, name="verification_congrats"), + path("profile-congrats/", profile_congrats, name="profile_congrats"), + path("", my_fire_view, name="mi_fuego"), + path("mis-bonos/", my_ticket_view, name="my_ticket"), + path( + "mis-bonos/bonos-transferibles", + transferable_tickets_view, + name="transferable_tickets", + ), + path("mis-bonos/volunteering/", volunteering, name="volunteering"), +] diff --git a/user_profile/views.py b/user_profile/views.py new file mode 100644 index 0000000..a075bd3 --- /dev/null +++ b/user_profile/views.py @@ -0,0 +1,190 @@ +from django.contrib.auth.decorators import login_required +from django.db import transaction +from django.http import Http404 +from django.shortcuts import render, redirect, get_object_or_404 +from django.urls import reverse +from django.utils import timezone + +from events.models import Event +from tickets.models import NewTicket, NewTicketTransfer +from .forms import ProfileStep1Form, ProfileStep2Form, VolunteeringForm + + +@login_required +def my_fire_view(request): + return redirect(reverse("my_ticket")) + + +@login_required +def my_ticket_view(request): + event = Event.objects.get(active=True) + my_ticket = NewTicket.objects.filter( + holder=request.user, event=event, owner=request.user + ).first() + + return render( + request, + "mi_fuego/my_tickets/my_ticket.html", + { + "is_volunteer": my_ticket.is_volunteer() if my_ticket else False, + "my_ticket": my_ticket.get_dto(user=request.user) if my_ticket else None, + "event": event, + "nav_primary": "tickets", + "nav_secondary": "my_ticket", + 'now': timezone.now(), + }, + ) + + +@login_required +def transferable_tickets_view(request): + event = Event.objects.get(active=True) + tickets = ( + NewTicket.objects.filter(holder=request.user, event=event) + .exclude(owner=request.user) + .order_by("owner") + .all() + ) + my_ticket = NewTicket.objects.filter( + holder=request.user, event=event, owner=request.user + ).first() + + tickets_dto = [] + + for ticket in tickets: + tickets_dto.append(ticket.get_dto(user=request.user)) + + transferred_tickets = NewTicketTransfer.objects.filter( + tx_from=request.user, status="COMPLETED" + ).all() + transferred_dto = [] + for transfer in transferred_tickets: + transferred_dto.append( + { + "tx_to_email": transfer.tx_to_email, + "ticket_key": transfer.ticket.key, + "ticket_type": transfer.ticket.ticket_type.name, + "ticket_color": transfer.ticket.ticket_type.color, + "emoji": transfer.ticket.ticket_type.emoji, + } + ) + + return render( + request, + "mi_fuego/my_tickets/transferable_tickets.html", + { + "my_ticket": my_ticket, + "tickets_dto": tickets_dto, + "transferred_dto": transferred_dto, + "event": event, + "nav_primary": "tickets", + "nav_secondary": "transferable_tickets", + }, + ) + + +@login_required +def complete_profile(request): + profile = request.user.profile + error_message = None + code_sent = False + + if profile.profile_completion == "NONE": + if request.method == "POST": + form = ProfileStep1Form(request.POST, instance=profile, user=request.user) + if form.is_valid(): + form.save() + profile.profile_completion = "INITIAL_STEP" + profile.save() + return redirect("complete_profile") + else: + form = ProfileStep1Form(instance=profile, user=request.user) + return render(request, "account/complete_profile_step1.html", {"form": form}) + + elif profile.profile_completion == "INITIAL_STEP": + form = ProfileStep2Form(request.POST or None, instance=profile) + if request.method == "POST": + if "send_code" in request.POST: + if form.is_valid(): + form.save() + form.send_verification_code() + code_sent = True + elif "verify_code" in request.POST: + code_sent = True + form = ProfileStep2Form(request.POST, instance=profile, code_sent=True) + if form.is_valid(): + if form.verify_code(): + profile.profile_completion = "COMPLETE" + profile.save() + return profile_congrats(request) + else: + error_message = "Código inválido. Por favor, intenta de nuevo." + else: + form = ProfileStep2Form(request.POST, instance=profile, code_sent=True) + + return render( + request, + "account/complete_profile_step2.html", + { + "form": form, + "error_message": error_message, + "code_sent": code_sent, + "profile": profile, + }, + ) + else: + return redirect("home") + + +@login_required +def profile_congrats(request): + user = request.user + pending_transfers = ( + NewTicketTransfer.objects.filter(tx_to_email=user.email, status="PENDING") + .select_related("ticket") + .all() + ) + + if pending_transfers.exists(): + with transaction.atomic(): + user_already_has_ticket = NewTicket.objects.filter(owner=user).exists() + for transfer in pending_transfers: + transfer.status = "COMPLETED" + transfer.tx_to = user + transfer.save() + + transfer.ticket.holder = user + transfer.ticket.volunteer_ranger = None + transfer.ticket.volunteer_transmutator = None + transfer.ticket.volunteer_umpalumpa = None + if user_already_has_ticket: + transfer.ticket.owner = None + else: + transfer.ticket.owner = user + user_already_has_ticket = True + + transfer.ticket.save() + + return render(request, "account/profile_congrats_with_tickets.html") + else: + return render(request, "account/profile_congrats.html") + + +def verification_congrats(request): + return render(request, "account/verification_congrats.html") + + +@login_required +def volunteering(request): + ticket = get_object_or_404(NewTicket, holder=request.user, owner=request.user) + + if request.method == "POST": + if ticket.event.volunteer_period() is False: + raise Http404 + form = VolunteeringForm(request.POST, instance=ticket) + if form.is_valid(): + form.save() + else: + form = VolunteeringForm(instance=ticket) + + return render(request, "mi_fuego/my_tickets/volunteering.html", {"form": form, "nav_primary": "volunteering"}) diff --git a/utils/context_processors.py b/utils/context_processors.py index 6aebb54..0fc96f2 100644 --- a/utils/context_processors.py +++ b/utils/context_processors.py @@ -1,11 +1,68 @@ +import hashlib +import hmac + +from deprepagos import settings from events.models import Event +from tickets.models import NewTicket + def current_event(request): try: event = Event.objects.get(active=True) except Event.DoesNotExist: - event = Event.objects.latest('id') - return { - 'event': event, + event = Event.objects.latest("id") + + context = { + "event": event, } + if request.user.is_authenticated: + + tickets = NewTicket.objects.filter( + holder=request.user, event=event, owner=None + ).all() + + tickets_dto = [] + + for ticket in tickets: + tickets_dto.append(ticket.get_dto(user=request.user)) + + has_unassigned_tickets = any( + ticket["is_owners"] is False for ticket in tickets_dto + ) + has_transfer_pending = any( + ticket["is_transfer_pending"] is True for ticket in tickets_dto + ) + context.update( + { + "has_unassigned_tickets": has_unassigned_tickets, + "has_transfer_pending": has_transfer_pending, + } + ) + return context + + +def app_url(request): + return {"APP_URL": settings.APP_URL} + + +def donation_amount(request): + return {"DONATION_AMOUNT": settings.DONATION_AMOUNT} + + +def chatwoot_token(request): + return {"CHATWOOT_TOKEN": settings.CHATWOOT_TOKEN} + + +def env(request): + return {"ENV": settings.ENV} + + +def chatwoot_identifier_hash(request): + if hasattr(request, "user"): + secret = bytes(settings.CHATWOOT_IDENTITY_VALIDATION, "utf-8") + message = bytes(request.user.username, "utf-8") + hash = hmac.new(secret, message, hashlib.sha256) + identifier_hash = hash.hexdigest() + return {"CHATWOOT_IDENTIFIER_HASH": identifier_hash} + return {"CHATWOOT_IDENTIFIER_HASH": None} diff --git a/utils/direct_sales.py b/utils/direct_sales.py new file mode 100644 index 0000000..d68ae6f --- /dev/null +++ b/utils/direct_sales.py @@ -0,0 +1,132 @@ +from urllib.parse import urlencode + +from django.db import transaction +from django.urls import reverse + +from tickets.models import Order, NewTicket, TicketType, DirectTicketTemplate, DirectTicketTemplateStatus, \ + NewTicketTransfer +from utils.email import send_mail + + +def direct_sales_existing_user(user, template_tickets, order_type, notes, request_user): + with transaction.atomic(): + order = Order( + first_name=user.first_name, + last_name=user.last_name, + email=user.email, + phone=user.profile.phone, + dni=user.profile.document_number, + amount=0, + status=Order.OrderStatus.CONFIRMED, + event_id=template_tickets[0]['event_id'], + user=user, + order_type=order_type, + donation_art=0, + donation_venue=0, + donation_grant=0, + notes=notes, + generated_by=request_user + ) + order.save() + + user_already_has_ticket = NewTicket.objects.filter(owner=user).exists() + ticket_type = TicketType.objects.get(event_id=template_tickets[0]['event_id'], + is_direct_type=True) + emitted_tickets = 0 + first_ticket = True + for template_ticket in template_tickets: + if template_ticket['amount'] > 0: + for i in range(template_ticket['amount']): + new_ticket = NewTicket( + holder=user, + ticket_type=ticket_type, + event_id=template_ticket['event_id'], + order=order + ) + + if not user_already_has_ticket and first_ticket: + new_ticket.owner = user + + first_ticket = False + new_ticket.save() + emitted_tickets += 1 + + template = DirectTicketTemplate.objects.get(id=template_ticket['id']) + template.status = DirectTicketTemplateStatus.ASSIGNED + template.save() + + order.amount = emitted_tickets * ticket_type.price + order.save() + + send_mail( + template_name='new_transfer_success', + recipient_list=[user.email], + context={ + 'ticket_count': emitted_tickets, + } + ) + + return order.id + + +def direct_sales_new_user(destination_email, template_tickets, order_type, notes, request_user): + with transaction.atomic(): + order = Order( + amount=0, + status=Order.OrderStatus.CONFIRMED, + event_id=template_tickets[0]['event_id'], + email=destination_email, + + order_type=order_type, + donation_art=0, + donation_venue=0, + donation_grant=0, + notes=notes, + generated_by=request_user + ) + order.save() + + ticket_type = TicketType.objects.get(event_id=template_tickets[0]['event_id'], + is_direct_type=True) + emitted_tickets = 0 + for template_ticket in template_tickets: + print(template_ticket) + if template_ticket['amount'] > 0: + for i in range(template_ticket['amount']): + new_ticket = NewTicket( + ticket_type=ticket_type, + event_id=template_ticket['event_id'], + order=order, + ) + + new_ticket.save() + + new_ticket_transfer = NewTicketTransfer( + ticket=new_ticket, + # TODO fix hack, se simula el envio desde el usuario que realiza la compra. Deberiia ser SISTEMA, o implementar tx_from_email + tx_from=request_user, + + tx_to_email=destination_email, + status='PENDING' + ) + new_ticket_transfer.save() + + emitted_tickets += 1 + + template = DirectTicketTemplate.objects.get(id=template_ticket['id']) + template.status = DirectTicketTemplateStatus.PENDING + template.save() + + order.amount = emitted_tickets * ticket_type.price + order.save() + + send_mail( + template_name='new_transfer_no_account', + recipient_list=[destination_email], + context={ + 'ticket_count': emitted_tickets, + 'destination_email': destination_email, + 'sign_up_link': f"{reverse('account_signup')}?{urlencode({'email': destination_email})}" + } + ) + return order.id diff --git a/zappa_settings.json b/zappa_settings.json index 980ac88..2328fbd 100644 --- a/zappa_settings.json +++ b/zappa_settings.json @@ -1,63 +1,86 @@ { - "dev": { - "aws_region": "us-east-1", - "django_settings": "deprepagos.settings_dev", - "profile_name": "default", - "project_name": "deprepagos", - "runtime": "python3.8", - "timeout_seconds": 30, - "s3_bucket": "faticketera-zappa-dev", - "vpc_config" : { - "SubnetIds": [ - "subnet-0d7a6e86d022b6ce5", - "subnet-0be4af32a014a1734", - "subnet-0a902c2f8d32f26f5", - "subnet-0d3504ba22b734981", - "subnet-01b326364992b2f57", - "subnet-0a10faa62f52d05a8" - ], - "SecurityGroupIds": ["sg-05430820583c3e497"] - }, - "extra_permissions": [{ - "Effect": "Allow", - "Action": ["s3:*"], - "Resource": "arn:aws:s3:::faticketera-zappa-dev/*" - }] - // "exclude": ["local_settings.py"], - // "exclude_glob": ["venv/*"] - // "domain": "bonos.fa2022.org", - // "certificate_arn": "arn:aws:acm:us-east-1:153920312805:certificate/7c83858c-a0e9-4396-b1d2-b596e15dac52" + "dev": { + "slim_handler": true, + "aws_region": "us-east-1", + "django_settings": "deprepagos.settings_dev", + "project_name": "deprepagos", + "runtime": "python3.9", + "timeout_seconds": 30, + "s3_bucket": "faticketera-zappa-dev", + "keep_warm": true, + "vpc_config": { + "SubnetIds": [ + "subnet-0d7a6e86d022b6ce5", + "subnet-0be4af32a014a1734", + "subnet-0a902c2f8d32f26f5", + "subnet-0d3504ba22b734981", + "subnet-01b326364992b2f57", + "subnet-0a10faa62f52d05a8" + ], + "SecurityGroupIds": [ + "sg-05430820583c3e497" + ] }, - "prod": { - "aws_region": "us-east-1", - "django_settings": "deprepagos.settings_prod", - "profile_name": "default", - "project_name": "deprepagos", - "runtime": "python3.8", - "timeout_seconds": 30, - "s3_bucket": "faticketera-zappa-prod", - "vpc_config" : { - "SubnetIds": [ - "subnet-0130a8113f5e1feb7", - "subnet-0ec9b01ef200e3fd8", - "subnet-0ca111a1082be3412", - "subnet-087803ad71197bfd6", - "subnet-09df22997bd332147", - "subnet-0c0872c4d05ed309d" - ], - "SecurityGroupIds": ["sg-0c63c3a7d4263c4d4"] - }, - "domain": "eventos.fuegoaustral.org", - "route53_enabled": false, - "extra_permissions": [{ - "Effect": "Allow", - "Action": [ - "s3:PutObject", - "s3:GetObjectAcl", - "s3:GetObject" - ], - "Resource": "arn:aws:s3:::faticketera-zappa-prod/*" - }], - "certificate_arn": "arn:aws:acm:us-east-1:251799394474:certificate/84e0a65b-041f-49f0-8fb5-f440fb700aef" - } + "route53_enabled": false, + "extra_permissions": [ + { + "Effect": "Allow", + "Action": [ + "s3:*" + ], + "Resource": "arn:aws:s3:::faticketera-zappa-dev/*" + } + ], + "domain": "dev.fuegoaustral.org", + "certificate_arn": "arn:aws:acm:us-east-1:251799394474:certificate/09f6bc34-2d5a-4172-a25a-2602f1b15fca", + "events": [ + { + "function": "tickets.email_crons.send_pending_actions_emails", + "expression": "cron(0 17 * * ? *)" + } + ] + }, + "prod": { + "slim_handler": true, + "aws_region": "us-east-1", + "django_settings": "deprepagos.settings_prod", + "project_name": "deprepagos", + "runtime": "python3.9", + "timeout_seconds": 30, + "s3_bucket": "faticketera-zappa-prod", + "keep_warm": true, + "vpc_config": { + "SubnetIds": [ + "subnet-0130a8113f5e1feb7", + "subnet-0ec9b01ef200e3fd8", + "subnet-0ca111a1082be3412", + "subnet-087803ad71197bfd6", + "subnet-09df22997bd332147", + "subnet-0c0872c4d05ed309d" + ], + "SecurityGroupIds": [ + "sg-0c63c3a7d4263c4d4" + ] + }, + "domain": "eventos.fuegoaustral.org", + "route53_enabled": false, + "extra_permissions": [ + { + "Effect": "Allow", + "Action": [ + "s3:PutObject", + "s3:GetObjectAcl", + "s3:GetObject" + ], + "Resource": "arn:aws:s3:::faticketera-zappa-prod/*" + } + ], + "certificate_arn": "arn:aws:acm:us-east-1:251799394474:certificate/84e0a65b-041f-49f0-8fb5-f440fb700aef", + "events": [ + { + "function": "tickets.email_crons.send_pending_actions_emails", + "expression": "cron(0 17 * * ? *)" + } + ] + } }