From d76e783517b7ccb20d81fac7e3f039a7974df03a Mon Sep 17 00:00:00 2001 From: thenav56 Date: Sat, 3 Aug 2024 20:48:31 +0545 Subject: [PATCH 01/51] Add google oauth --- Dockerfile | 4 +- TODO.md | 1 + apps/common/templates/common/sign_in.html | 92 +++++++++ apps/common/views.py | 68 +++++++ apps/standup/apps.py | 6 + apps/standup/models.py | 11 + main/settings.py | 15 ++ main/urls.py | 26 +-- poetry.lock | 238 +++++++++++++++++++++- pyproject.toml | 3 +- 10 files changed, 435 insertions(+), 29 deletions(-) create mode 100644 TODO.md create mode 100644 apps/common/templates/common/sign_in.html create mode 100644 apps/common/views.py create mode 100644 apps/standup/apps.py create mode 100644 apps/standup/models.py diff --git a/Dockerfile b/Dockerfile index f571a61..9320baf 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,8 +1,8 @@ -FROM python:3.12-slim-bullseye as base +FROM python:3.12-slim-bullseye LABEL maintainer="Togglecorp Dev" -ENV PYTHONUNBUFFERED 1 +ENV PYTHONUNBUFFERED=1 WORKDIR /code diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..a52c8a9 --- /dev/null +++ b/TODO.md @@ -0,0 +1 @@ +- MFA: https://django-mfa.readthedocs.io/en/latest/ diff --git a/apps/common/templates/common/sign_in.html b/apps/common/templates/common/sign_in.html new file mode 100644 index 0000000..77598fe --- /dev/null +++ b/apps/common/templates/common/sign_in.html @@ -0,0 +1,92 @@ + + + + + Login Page + + + + + +
+ {% if request.user.is_authenticated %} +
+

Hi {{ request.user.display_name }} 🙂

+

Your email is {{ request.user.email }}

+
+ {% else %} +
+

Hi there 🙂

+

Click below to sign in with Google

+ +

Client ID: {{ GOOGLE_OAUTH_CLIENT_ID }}

+

Redirect URI: {{ GOOGLE_OAUTH_REDIRECT_URL }}

+ +
+
+ + + +
+ {% endif %} +
+ + + diff --git a/apps/common/views.py b/apps/common/views.py new file mode 100644 index 0000000..3d41911 --- /dev/null +++ b/apps/common/views.py @@ -0,0 +1,68 @@ +from django.http import HttpResponse +from django.shortcuts import render, redirect +from django.views.decorators.csrf import csrf_exempt +from django.conf import settings +from django.contrib.auth import login +from google.oauth2 import id_token +from google.auth.transport import requests + +from apps.user.models import User + + +@csrf_exempt +def dev_sign_in(request): + """ + For server-side development only, Used to simulate frontend sign_in + """ + return render( + request, + 'common/sign_in.html', + context=dict( + GOOGLE_OAUTH_CLIENT_ID=settings.GOOGLE_OAUTH_CLIENT_ID, + GOOGLE_OAUTH_REDIRECT_URL=settings.GOOGLE_OAUTH_REDIRECT_URL, + ), + ) + + +@csrf_exempt +def google_oauth(request): + """ + Google calls this URL after the user has signed in with their Google account. + """ + token = request.POST['credential'] + + try: + user_data = id_token.verify_oauth2_token( + token, requests.Request(), settings.GOOGLE_OAUTH_CLIENT_ID + ) + """ + { + 'hd': 'togglecorp.com', + 'email': 'xxxxxxxxx@togglecorp.com', + 'email_verified': True, + 'picture': 'https://lh3.googleusercontent.com/a/xx', + 'given_name': 'XXXXX', + 'family_name': 'YYYY', + } + """ + # TODO: Handle this properly + assert user_data["email_verified"] is True + except ValueError: + return HttpResponse(status=403) + + email = user_data['email'].lowercase() + if user := User.objects.filter(email=email).first(): + user.first_name = user_data['given_name'] + user.last_name = user_data['family_name'] + # TODO: User picture? + user.save(update_fields=('first_name', 'last_name')) + login(request, user) + else: + new_user = User.objects.create( + email=email, + first_name=user_data['given_name'], + last_name=user_data['family_name'], + ) + login(request, new_user) + + return redirect(settings.APP_FRONTEND_HOST) diff --git a/apps/standup/apps.py b/apps/standup/apps.py new file mode 100644 index 0000000..0db603c --- /dev/null +++ b/apps/standup/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class StandupConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.standup" diff --git a/apps/standup/models.py b/apps/standup/models.py new file mode 100644 index 0000000..f8d2575 --- /dev/null +++ b/apps/standup/models.py @@ -0,0 +1,11 @@ +# from django.db import models + +# from apps.user.models import User + + +# class DailyUserStandup(models.Model): +# user = models.ForeignKey(User, on_delete=models.CASCADE) +# date = models.DateField() + +# slack_thread_id = models.CharField(max_length=200) # TODO: Check length +# text = models.TextField() # TODO: Do we need this? diff --git a/main/settings.py b/main/settings.py index 97652c0..162bd21 100644 --- a/main/settings.py +++ b/main/settings.py @@ -84,6 +84,11 @@ SMTP_EMAIL_PORT=int, SMTP_EMAIL_USERNAME=str, SMTP_EMAIL_PASSWORD=str, + # Google SSO + USE_GOOGLE_OAUTH=(bool, False), + GOOGLE_OAUTH_CLIENT_ID=(str, None), + GOOGLE_OAUTH_SECRET=(str, None), + GOOGLE_OAUTH_REDIRECT_URL=(str, None), # MISC ALLOW_DUMMY_DATA_SCRIPT=(bool, False), # WARNING ) @@ -126,6 +131,7 @@ "corsheaders", # Internal apps "apps.common", # Common + "apps.standup", "apps.user", "apps.project", "apps.track", @@ -424,3 +430,12 @@ CELERY_EVENT_QUEUE_PREFIX = "timur-celery-" CELERY_ACKS_LATE = True CELERY_BROKER_CONNECTION_RETRY_ON_STARTUP = True + +# Google SSO +USE_GOOGLE_OAUTH = env("USE_GOOGLE_OAUTH") +GOOGLE_OAUTH_CLIENT_ID = env("GOOGLE_OAUTH_CLIENT_ID") +GOOGLE_OAUTH_SECRET = env("GOOGLE_OAUTH_SECRET") +GOOGLE_OAUTH_REDIRECT_URL = env("GOOGLE_OAUTH_REDIRECT_URL") +# TODO: We need these lines below to allow the Google sign in popup to work. +SECURE_REFERRER_POLICY = "no-referrer-when-downgrade" +SECURE_CROSS_ORIGIN_OPENER_POLICY = "same-origin-allow-popups" diff --git a/main/urls.py b/main/urls.py index ed88331..1c0b33d 100644 --- a/main/urls.py +++ b/main/urls.py @@ -1,25 +1,9 @@ -""" -URL configuration for main project. - -The `urlpatterns` list routes URLs to views. For more information please see: - https://docs.djangoproject.com/en/5.0/topics/http/urls/ -Examples: -Function views - 1. Add an import: from my_app import views - 2. Add a URL to urlpatterns: path('', views.home, name='home') -Class-based views - 1. Add an import: from other_app.views import Home - 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') -Including another URLconf - 1. Import the include() function: from django.urls import include, path - 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) -""" - from django.conf import settings from django.conf.urls.static import static from django.contrib import admin from django.urls import path +from apps.common.views import dev_sign_in, google_oauth from main.graphql.schema import CustomAsyncGraphQLView from main.graphql.schema import schema as graphql_schema @@ -33,11 +17,17 @@ graphiql=False, ), ), + path("o/google", google_oauth), ] if settings.DEBUG: - urlpatterns.append(path("graphiql/", CustomAsyncGraphQLView.as_view(schema=graphql_schema))) + urlpatterns.extend( + [ + path("graphiql/", CustomAsyncGraphQLView.as_view(schema=graphql_schema)), + path("dev/sign_in/", dev_sign_in, name="dev-sign-in"), + ] + ) # Static and media file URLs urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) diff --git a/poetry.lock b/poetry.lock index 3935a6c..8ad93f1 100644 --- a/poetry.lock +++ b/poetry.lock @@ -130,6 +130,17 @@ urllib3 = {version = ">=1.25.4,<2.2.0 || >2.2.0,<3", markers = "python_version > [package.extras] crt = ["awscrt (==0.20.11)"] +[[package]] +name = "cachetools" +version = "5.4.0" +description = "Extensible memoizing collections and decorators" +optional = false +python-versions = ">=3.7" +files = [ + {file = "cachetools-5.4.0-py3-none-any.whl", hash = "sha256:3ae3b49a3d5e28a77a0be2b37dbcb89005058959cb2323858c2657c4a8cab474"}, + {file = "cachetools-5.4.0.tar.gz", hash = "sha256:b8adc2e7c07f105ced7bc56dbb6dfbe7c4a00acce20e2227b3f355be89bc6827"}, +] + [[package]] name = "cattrs" version = "23.2.3" @@ -687,13 +698,13 @@ django = ">=3.2" [[package]] name = "django-ses" -version = "3.0.0" -description = "A Django email backend for Amazon's Simple Email Service" +version = "3.6.0" +description = "A Django email backend for Amazon's Simple Email Service (SES)" optional = false -python-versions = ">=3.7,<4.0" +python-versions = "<4.0,>=3.8" files = [ - {file = "django-ses-3.0.0.tar.gz", hash = "sha256:8066b391327dce4d8061ee3656218370670183151db4472e5a1292d80828e4ca"}, - {file = "django_ses-3.0.0-py3-none-any.whl", hash = "sha256:7299a9b2b747c0f62a07566a2e40d4270343098e9f075ba8093541951ce27e61"}, + {file = "django_ses-3.6.0-py3-none-any.whl", hash = "sha256:f3f69b97444fdbda41946c7349c63e1a0ea8284d9e9acd6f4b5cb3dba5030829"}, + {file = "django_ses-3.6.0.tar.gz", hash = "sha256:ea08bea9e1aab71f9fbf43b30733a27eff76cea3797b7ebeab9f6bc5d3df6b37"}, ] [package.dependencies] @@ -702,8 +713,8 @@ django = ">=2.2" pytz = ">=2016.10" [package.extras] -bounce = ["cryptography (>=36.0.2,<37.0.0)", "requests (>=2.27.1,<3.0.0)"] -events = ["cryptography (>=36.0.2,<37.0.0)", "requests (>=2.27.1,<3.0.0)"] +bounce = ["cryptography (>=36.0.2)", "requests (>=2.27.1)"] +events = ["cryptography (>=36.0.2)", "requests (>=2.27.1)"] [[package]] name = "django-storages" @@ -858,6 +869,102 @@ files = [ [package.dependencies] python-dateutil = ">=2.4" +[[package]] +name = "google-api-core" +version = "2.19.1" +description = "Google API client core library" +optional = false +python-versions = ">=3.7" +files = [ + {file = "google-api-core-2.19.1.tar.gz", hash = "sha256:f4695f1e3650b316a795108a76a1c416e6afb036199d1c1f1f110916df479ffd"}, + {file = "google_api_core-2.19.1-py3-none-any.whl", hash = "sha256:f12a9b8309b5e21d92483bbd47ce2c445861ec7d269ef6784ecc0ea8c1fa6125"}, +] + +[package.dependencies] +google-auth = ">=2.14.1,<3.0.dev0" +googleapis-common-protos = ">=1.56.2,<2.0.dev0" +proto-plus = ">=1.22.3,<2.0.0dev" +protobuf = ">=3.19.5,<3.20.0 || >3.20.0,<3.20.1 || >3.20.1,<4.21.0 || >4.21.0,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<6.0.0.dev0" +requests = ">=2.18.0,<3.0.0.dev0" + +[package.extras] +grpc = ["grpcio (>=1.33.2,<2.0dev)", "grpcio (>=1.49.1,<2.0dev)", "grpcio-status (>=1.33.2,<2.0.dev0)", "grpcio-status (>=1.49.1,<2.0.dev0)"] +grpcgcp = ["grpcio-gcp (>=0.2.2,<1.0.dev0)"] +grpcio-gcp = ["grpcio-gcp (>=0.2.2,<1.0.dev0)"] + +[[package]] +name = "google-api-python-client" +version = "2.138.0" +description = "Google API Client Library for Python" +optional = false +python-versions = ">=3.7" +files = [ + {file = "google_api_python_client-2.138.0-py2.py3-none-any.whl", hash = "sha256:1dd279124e4e77cbda4769ffb4abe7e7c32528ef1e18739320fef2a07b750764"}, + {file = "google_api_python_client-2.138.0.tar.gz", hash = "sha256:31080fbf0e64687876135cc23d1bec1ca3b80d7702177dd17b04131ea889eb70"}, +] + +[package.dependencies] +google-api-core = ">=1.31.5,<2.0.dev0 || >2.3.0,<3.0.0.dev0" +google-auth = ">=1.32.0,<2.24.0 || >2.24.0,<2.25.0 || >2.25.0,<3.0.0.dev0" +google-auth-httplib2 = ">=0.2.0,<1.0.0" +httplib2 = ">=0.19.0,<1.dev0" +uritemplate = ">=3.0.1,<5" + +[[package]] +name = "google-auth" +version = "2.32.0" +description = "Google Authentication Library" +optional = false +python-versions = ">=3.7" +files = [ + {file = "google_auth-2.32.0-py2.py3-none-any.whl", hash = "sha256:53326ea2ebec768070a94bee4e1b9194c9646ea0c2bd72422785bd0f9abfad7b"}, + {file = "google_auth-2.32.0.tar.gz", hash = "sha256:49315be72c55a6a37d62819e3573f6b416aca00721f7e3e31a008d928bf64022"}, +] + +[package.dependencies] +cachetools = ">=2.0.0,<6.0" +pyasn1-modules = ">=0.2.1" +rsa = ">=3.1.4,<5" + +[package.extras] +aiohttp = ["aiohttp (>=3.6.2,<4.0.0.dev0)", "requests (>=2.20.0,<3.0.0.dev0)"] +enterprise-cert = ["cryptography (==36.0.2)", "pyopenssl (==22.0.0)"] +pyopenssl = ["cryptography (>=38.0.3)", "pyopenssl (>=20.0.0)"] +reauth = ["pyu2f (>=0.1.5)"] +requests = ["requests (>=2.20.0,<3.0.0.dev0)"] + +[[package]] +name = "google-auth-httplib2" +version = "0.2.0" +description = "Google Authentication Library: httplib2 transport" +optional = false +python-versions = "*" +files = [ + {file = "google-auth-httplib2-0.2.0.tar.gz", hash = "sha256:38aa7badf48f974f1eb9861794e9c0cb2a0511a4ec0679b1f886d108f5640e05"}, + {file = "google_auth_httplib2-0.2.0-py2.py3-none-any.whl", hash = "sha256:b65a0a2123300dd71281a7bf6e64d65a0759287df52729bdd1ae2e47dc311a3d"}, +] + +[package.dependencies] +google-auth = "*" +httplib2 = ">=0.19.0" + +[[package]] +name = "googleapis-common-protos" +version = "1.63.2" +description = "Common protobufs used in Google APIs" +optional = false +python-versions = ">=3.7" +files = [ + {file = "googleapis-common-protos-1.63.2.tar.gz", hash = "sha256:27c5abdffc4911f28101e635de1533fb4cfd2c37fbaa9174587c799fac90aa87"}, + {file = "googleapis_common_protos-1.63.2-py2.py3-none-any.whl", hash = "sha256:27a2499c7e8aff199665b22741997e485eccc8645aa9176c7c988e6fae507945"}, +] + +[package.dependencies] +protobuf = ">=3.20.2,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<6.0.0.dev0" + +[package.extras] +grpc = ["grpcio (>=1.44.0,<2.0.0.dev0)"] + [[package]] name = "gprof2dot" version = "2024.6.6" @@ -880,6 +987,20 @@ files = [ {file = "graphql_core-3.2.3-py3-none-any.whl", hash = "sha256:5766780452bd5ec8ba133f8bf287dc92713e3868ddd83aee4faab9fc3e303dc3"}, ] +[[package]] +name = "httplib2" +version = "0.22.0" +description = "A comprehensive HTTP client library." +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +files = [ + {file = "httplib2-0.22.0-py3-none-any.whl", hash = "sha256:14ae0a53c1ba8f3d37e9e27cf37eabb0fb9980f435ba405d546948b009dd64dc"}, + {file = "httplib2-0.22.0.tar.gz", hash = "sha256:d7a10bc5ef5ab08322488bde8c726eeee5c8618723fdb399597ec58f3d82df81"}, +] + +[package.dependencies] +pyparsing = {version = ">=2.4.2,<3.0.0 || >3.0.0,<3.0.1 || >3.0.1,<3.0.2 || >3.0.2,<3.0.3 || >3.0.3,<4", markers = "python_version > \"3.0\""} + [[package]] name = "idna" version = "3.7" @@ -1338,6 +1459,43 @@ files = [ [package.dependencies] wcwidth = "*" +[[package]] +name = "proto-plus" +version = "1.24.0" +description = "Beautiful, Pythonic protocol buffers." +optional = false +python-versions = ">=3.7" +files = [ + {file = "proto-plus-1.24.0.tar.gz", hash = "sha256:30b72a5ecafe4406b0d339db35b56c4059064e69227b8c3bda7462397f966445"}, + {file = "proto_plus-1.24.0-py3-none-any.whl", hash = "sha256:402576830425e5f6ce4c2a6702400ac79897dab0b4343821aa5188b0fab81a12"}, +] + +[package.dependencies] +protobuf = ">=3.19.0,<6.0.0dev" + +[package.extras] +testing = ["google-api-core (>=1.31.5)"] + +[[package]] +name = "protobuf" +version = "5.27.2" +description = "" +optional = false +python-versions = ">=3.8" +files = [ + {file = "protobuf-5.27.2-cp310-abi3-win32.whl", hash = "sha256:354d84fac2b0d76062e9b3221f4abbbacdfd2a4d8af36bab0474f3a0bb30ab38"}, + {file = "protobuf-5.27.2-cp310-abi3-win_amd64.whl", hash = "sha256:0e341109c609749d501986b835f667c6e1e24531096cff9d34ae411595e26505"}, + {file = "protobuf-5.27.2-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:a109916aaac42bff84702fb5187f3edadbc7c97fc2c99c5ff81dd15dcce0d1e5"}, + {file = "protobuf-5.27.2-cp38-abi3-manylinux2014_aarch64.whl", hash = "sha256:176c12b1f1c880bf7a76d9f7c75822b6a2bc3db2d28baa4d300e8ce4cde7409b"}, + {file = "protobuf-5.27.2-cp38-abi3-manylinux2014_x86_64.whl", hash = "sha256:b848dbe1d57ed7c191dfc4ea64b8b004a3f9ece4bf4d0d80a367b76df20bf36e"}, + {file = "protobuf-5.27.2-cp38-cp38-win32.whl", hash = "sha256:4fadd8d83e1992eed0248bc50a4a6361dc31bcccc84388c54c86e530b7f58863"}, + {file = "protobuf-5.27.2-cp38-cp38-win_amd64.whl", hash = "sha256:610e700f02469c4a997e58e328cac6f305f649826853813177e6290416e846c6"}, + {file = "protobuf-5.27.2-cp39-cp39-win32.whl", hash = "sha256:9e8f199bf7f97bd7ecebffcae45ebf9527603549b2b562df0fbc6d4d688f14ca"}, + {file = "protobuf-5.27.2-cp39-cp39-win_amd64.whl", hash = "sha256:7fc3add9e6003e026da5fc9e59b131b8f22b428b991ccd53e2af8071687b4fce"}, + {file = "protobuf-5.27.2-py3-none-any.whl", hash = "sha256:54330f07e4949d09614707c48b06d1a22f8ffb5763c159efd5c0928326a91470"}, + {file = "protobuf-5.27.2.tar.gz", hash = "sha256:f3ecdef226b9af856075f28227ff2c90ce3a594d092c39bee5513573f25e2714"}, +] + [[package]] name = "psycopg2-binary" version = "2.9.9" @@ -1444,6 +1602,31 @@ files = [ [package.extras] tests = ["pytest"] +[[package]] +name = "pyasn1" +version = "0.6.0" +description = "Pure-Python implementation of ASN.1 types and DER/BER/CER codecs (X.208)" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pyasn1-0.6.0-py2.py3-none-any.whl", hash = "sha256:cca4bb0f2df5504f02f6f8a775b6e416ff9b0b3b16f7ee80b5a3153d9b804473"}, + {file = "pyasn1-0.6.0.tar.gz", hash = "sha256:3a35ab2c4b5ef98e17dfdec8ab074046fbda76e281c5a706ccd82328cfc8f64c"}, +] + +[[package]] +name = "pyasn1-modules" +version = "0.4.0" +description = "A collection of ASN.1-based protocols modules" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pyasn1_modules-0.4.0-py3-none-any.whl", hash = "sha256:be04f15b66c206eed667e0bb5ab27e2b1855ea54a842e5037738099e8ca4ae0b"}, + {file = "pyasn1_modules-0.4.0.tar.gz", hash = "sha256:831dbcea1b177b28c9baddf4c6d1013c24c3accd14a1873fffaa6a2e905f17b6"}, +] + +[package.dependencies] +pyasn1 = ">=0.4.6,<0.7.0" + [[package]] name = "pycparser" version = "2.22" @@ -1469,6 +1652,20 @@ files = [ [package.extras] windows-terminal = ["colorama (>=0.4.6)"] +[[package]] +name = "pyparsing" +version = "3.1.2" +description = "pyparsing module - Classes and methods to define and execute parsing grammars" +optional = false +python-versions = ">=3.6.8" +files = [ + {file = "pyparsing-3.1.2-py3-none-any.whl", hash = "sha256:f9db75911801ed778fe61bb643079ff86601aca99fcae6345aa67292038fb742"}, + {file = "pyparsing-3.1.2.tar.gz", hash = "sha256:a1bac0ce561155ecc3ed78ca94d3c9378656ad4c94c1270de543f621420f94ad"}, +] + +[package.extras] +diagrams = ["jinja2", "railroad-diagrams"] + [[package]] name = "pytest" version = "8.3.2" @@ -1632,6 +1829,20 @@ redis = ["redis (>=3)"] security = ["itsdangerous (>=2.0)"] yaml = ["pyyaml (>=6.0.1)"] +[[package]] +name = "rsa" +version = "4.9" +description = "Pure-Python RSA implementation" +optional = false +python-versions = ">=3.6,<4" +files = [ + {file = "rsa-4.9-py3-none-any.whl", hash = "sha256:90260d9058e514786967344d0ef75fa8727eed8a7d2e43ce9f4bcf1b536174f7"}, + {file = "rsa-4.9.tar.gz", hash = "sha256:e38464a49c6c85d7f1351b0126661487a7e0a14a50f1675ec50eb34d4f20ef21"}, +] + +[package.dependencies] +pyasn1 = ">=0.1.3" + [[package]] name = "s3transfer" version = "0.10.2" @@ -1883,6 +2094,17 @@ files = [ {file = "ua_parser-0.18.0-py2.py3-none-any.whl", hash = "sha256:9d94ac3a80bcb0166823956a779186c746b50ea4c9fd9bf30fdb758553c38950"}, ] +[[package]] +name = "uritemplate" +version = "4.1.1" +description = "Implementation of RFC 6570 URI Templates" +optional = false +python-versions = ">=3.6" +files = [ + {file = "uritemplate-4.1.1-py2.py3-none-any.whl", hash = "sha256:830c08b8d99bdd312ea4ead05994a38e8936266f84b9a7878232db50b044e02e"}, + {file = "uritemplate-4.1.1.tar.gz", hash = "sha256:4346edfc5c3b79f694bccd6d6099a322bbeb628dbf2cd86eea55a456ce5124f0"}, +] + [[package]] name = "url-normalize" version = "1.4.3" @@ -1963,4 +2185,4 @@ files = [ [metadata] lock-version = "2.0" python-versions = "^3.12" -content-hash = "151e1ab304fecdcb91cb2ca76491afdc80dc6ce9b66524b0d04a44385cb3cbab" +content-hash = "1bc879f859bddd5739c8b32bf8b200c8f1df058af434509bdf3e1abc9c029a6a" diff --git a/pyproject.toml b/pyproject.toml index d542f3e..09630c4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,12 +24,13 @@ sentry-sdk = "*" ipython = "*" factory-boy = "*" user-agents = "*" -django-ses = "3" +django-ses = "^3" celery = {extras = ["redis"], version = "^5.3.4"} django-redis = "^5.3.0" django-reversion = "*" uwsgi = "*" aws-sns-message-validator = "*" +google-api-python-client = "*" [tool.poetry.dev-dependencies] dacite = "*" From a2cca255af069cc4fb5ef801d61df302c10db1f4 Mon Sep 17 00:00:00 2001 From: thenav56 Date: Mon, 5 Aug 2024 17:16:38 +0545 Subject: [PATCH 02/51] Auto-format migrations files as well --- .flake8 | 2 +- .pre-commit-config.yaml | 1 - apps/journal/migrations/0001_initial.py | 18 ++-- apps/journal/migrations/0002_initial.py | 20 +++-- apps/project/migrations/0001_initial.py | 63 ++++++++------ apps/project/migrations/0002_initial.py | 52 +++++++----- apps/track/migrations/0001_initial.py | 64 ++++++++------ apps/track/migrations/0002_initial.py | 60 +++++++------ ...ted_hours_task_estimated_hours_and_more.py | 26 ++++-- apps/user/migrations/0001_initial.py | 84 ++++++++++++++----- apps/user/migrations/0002_user_department.py | 17 +++- .../migrations/0003_alter_user_department.py | 18 +++- 12 files changed, 273 insertions(+), 152 deletions(-) diff --git a/.flake8 b/.flake8 index f792b65..8639974 100644 --- a/.flake8 +++ b/.flake8 @@ -1,5 +1,5 @@ [flake8] -extend-ignore = C901, W504 +extend-ignore = C901, W504, E701, E203, W503 max-line-length = 125 # NOTE: Update in .pre-commit-config.yaml as well extend-exclude = .git,__pycache__,old,build,dist,*/migrations/*.py,legacy/,.venv diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 83b5b70..f914675 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -7,7 +7,6 @@ exclude: | \.git| __pycache__| .*snap_test_.*\.py| - .+\/.+\/migrations\/.*| \.venv ) diff --git a/apps/journal/migrations/0001_initial.py b/apps/journal/migrations/0001_initial.py index 5df5667..c3dd0cf 100644 --- a/apps/journal/migrations/0001_initial.py +++ b/apps/journal/migrations/0001_initial.py @@ -7,17 +7,21 @@ class Migration(migrations.Migration): initial = True - dependencies = [ - ] + dependencies = [] operations = [ migrations.CreateModel( - name='Journal', + name="Journal", fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('date', models.DateField()), - ('leave_type', models.PositiveSmallIntegerField(blank=True, choices=[(1, 'Full'), (2, 'First Half'), (3, 'Second Half')], null=True)), - ('journal_text', models.TextField(blank=True)), + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("date", models.DateField()), + ( + "leave_type", + models.PositiveSmallIntegerField( + blank=True, choices=[(1, "Full"), (2, "First Half"), (3, "Second Half")], null=True + ), + ), + ("journal_text", models.TextField(blank=True)), ], ), ] diff --git a/apps/journal/migrations/0002_initial.py b/apps/journal/migrations/0002_initial.py index 840aa7a..8fd0790 100644 --- a/apps/journal/migrations/0002_initial.py +++ b/apps/journal/migrations/0002_initial.py @@ -1,8 +1,8 @@ # Generated by Django 4.2.13 on 2024-06-18 10:58 +import django.db.models.deletion from django.conf import settings from django.db import migrations, models -import django.db.models.deletion class Migration(migrations.Migration): @@ -11,21 +11,23 @@ class Migration(migrations.Migration): dependencies = [ migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ('journal', '0001_initial'), + ("journal", "0001_initial"), ] operations = [ migrations.AddField( - model_name='journal', - name='user', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to=settings.AUTH_USER_MODEL), + model_name="journal", + name="user", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, related_name="+", to=settings.AUTH_USER_MODEL + ), ), migrations.AddIndex( - model_name='journal', - index=models.Index(fields=['date'], name='journal_jou_date_eb308c_idx'), + model_name="journal", + index=models.Index(fields=["date"], name="journal_jou_date_eb308c_idx"), ), migrations.AlterUniqueTogether( - name='journal', - unique_together={('user', 'date')}, + name="journal", + unique_together={("user", "date")}, ), ] diff --git a/apps/project/migrations/0001_initial.py b/apps/project/migrations/0001_initial.py index dc3fbd6..90c0eb1 100644 --- a/apps/project/migrations/0001_initial.py +++ b/apps/project/migrations/0001_initial.py @@ -1,57 +1,66 @@ # Generated by Django 4.2.13 on 2024-06-18 10:58 -from django.db import migrations, models import django.db.models.deletion +from django.db import migrations, models class Migration(migrations.Migration): initial = True - dependencies = [ - ] + dependencies = [] operations = [ migrations.CreateModel( - name='Client', + name="Client", fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('modified_at', models.DateTimeField(auto_now=True)), - ('name', models.CharField(max_length=225)), + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("modified_at", models.DateTimeField(auto_now=True)), + ("name", models.CharField(max_length=225)), ], options={ - 'ordering': ['-id'], - 'abstract': False, + "ordering": ["-id"], + "abstract": False, }, ), migrations.CreateModel( - name='Contractor', + name="Contractor", fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('modified_at', models.DateTimeField(auto_now=True)), - ('name', models.CharField(max_length=225)), + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("modified_at", models.DateTimeField(auto_now=True)), + ("name", models.CharField(max_length=225)), ], options={ - 'ordering': ['-id'], - 'abstract': False, + "ordering": ["-id"], + "abstract": False, }, ), migrations.CreateModel( - name='Project', + name="Project", fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('modified_at', models.DateTimeField(auto_now=True)), - ('name', models.CharField(max_length=225)), - ('description', models.TextField(blank=True)), - ('client', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='projects', to='project.client')), - ('contractor', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='projects', to='project.contractor')), + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("modified_at", models.DateTimeField(auto_now=True)), + ("name", models.CharField(max_length=225)), + ("description", models.TextField(blank=True)), + ( + "client", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, related_name="projects", to="project.client" + ), + ), + ( + "contractor", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, related_name="projects", to="project.contractor" + ), + ), ], options={ - 'ordering': ['-id'], - 'abstract': False, + "ordering": ["-id"], + "abstract": False, }, ), ] diff --git a/apps/project/migrations/0002_initial.py b/apps/project/migrations/0002_initial.py index e104ea0..42e4db8 100644 --- a/apps/project/migrations/0002_initial.py +++ b/apps/project/migrations/0002_initial.py @@ -1,8 +1,8 @@ # Generated by Django 4.2.13 on 2024-06-18 10:58 +import django.db.models.deletion from django.conf import settings from django.db import migrations, models -import django.db.models.deletion class Migration(migrations.Migration): @@ -11,38 +11,50 @@ class Migration(migrations.Migration): dependencies = [ migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ('project', '0001_initial'), + ("project", "0001_initial"), ] operations = [ migrations.AddField( - model_name='project', - name='created_by', - field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='%(class)s_created', to=settings.AUTH_USER_MODEL), + model_name="project", + name="created_by", + field=models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, related_name="%(class)s_created", to=settings.AUTH_USER_MODEL + ), ), migrations.AddField( - model_name='project', - name='modified_by', - field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='%(class)s_modified', to=settings.AUTH_USER_MODEL), + model_name="project", + name="modified_by", + field=models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, related_name="%(class)s_modified", to=settings.AUTH_USER_MODEL + ), ), migrations.AddField( - model_name='contractor', - name='created_by', - field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='%(class)s_created', to=settings.AUTH_USER_MODEL), + model_name="contractor", + name="created_by", + field=models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, related_name="%(class)s_created", to=settings.AUTH_USER_MODEL + ), ), migrations.AddField( - model_name='contractor', - name='modified_by', - field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='%(class)s_modified', to=settings.AUTH_USER_MODEL), + model_name="contractor", + name="modified_by", + field=models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, related_name="%(class)s_modified", to=settings.AUTH_USER_MODEL + ), ), migrations.AddField( - model_name='client', - name='created_by', - field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='%(class)s_created', to=settings.AUTH_USER_MODEL), + model_name="client", + name="created_by", + field=models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, related_name="%(class)s_created", to=settings.AUTH_USER_MODEL + ), ), migrations.AddField( - model_name='client', - name='modified_by', - field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='%(class)s_modified', to=settings.AUTH_USER_MODEL), + model_name="client", + name="modified_by", + field=models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, related_name="%(class)s_modified", to=settings.AUTH_USER_MODEL + ), ), ] diff --git a/apps/track/migrations/0001_initial.py b/apps/track/migrations/0001_initial.py index b2c4334..9627e48 100644 --- a/apps/track/migrations/0001_initial.py +++ b/apps/track/migrations/0001_initial.py @@ -1,55 +1,65 @@ # Generated by Django 4.2.13 on 2024-06-18 10:58 -from django.db import migrations, models import django.db.models.deletion +from django.db import migrations, models class Migration(migrations.Migration): initial = True - dependencies = [ - ] + dependencies = [] operations = [ migrations.CreateModel( - name='Contract', + name="Contract", fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('modified_at', models.DateTimeField(auto_now=True)), - ('name', models.CharField(max_length=225)), - ('is_archived', models.BooleanField(default=False)), + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("modified_at", models.DateTimeField(auto_now=True)), + ("name", models.CharField(max_length=225)), + ("is_archived", models.BooleanField(default=False)), ], options={ - 'ordering': ['-id'], - 'abstract': False, + "ordering": ["-id"], + "abstract": False, }, ), migrations.CreateModel( - name='Task', + name="Task", fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('modified_at', models.DateTimeField(auto_now=True)), - ('name', models.CharField(max_length=225)), - ('is_archived', models.BooleanField(default=False)), + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("modified_at", models.DateTimeField(auto_now=True)), + ("name", models.CharField(max_length=225)), + ("is_archived", models.BooleanField(default=False)), ], options={ - 'ordering': ['-id'], - 'abstract': False, + "ordering": ["-id"], + "abstract": False, }, ), migrations.CreateModel( - name='TimeTrack', + name="TimeTrack", fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('date', models.DateField()), - ('task_type', models.PositiveSmallIntegerField(choices=[(1, 'Development'), (2, 'Quality Assurance'), (3, 'Designing'), (4, 'Meeting'), (5, 'Meeting (Internal)')])), - ('description', models.TextField(blank=True)), - ('is_done', models.BooleanField(default=False)), - ('duration', models.DurationField(blank=True, null=True)), - ('task', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='+', to='track.task')), + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("date", models.DateField()), + ( + "task_type", + models.PositiveSmallIntegerField( + choices=[ + (1, "Development"), + (2, "Quality Assurance"), + (3, "Designing"), + (4, "Meeting"), + (5, "Meeting (Internal)"), + ] + ), + ), + ("description", models.TextField(blank=True)), + ("is_done", models.BooleanField(default=False)), + ("duration", models.DurationField(blank=True, null=True)), + ("task", models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name="+", to="track.task")), ], ), ] diff --git a/apps/track/migrations/0002_initial.py b/apps/track/migrations/0002_initial.py index 7e2b9c3..92511e9 100644 --- a/apps/track/migrations/0002_initial.py +++ b/apps/track/migrations/0002_initial.py @@ -1,8 +1,8 @@ # Generated by Django 4.2.13 on 2024-06-18 10:58 +import django.db.models.deletion from django.conf import settings from django.db import migrations, models -import django.db.models.deletion class Migration(migrations.Migration): @@ -11,44 +11,56 @@ class Migration(migrations.Migration): dependencies = [ migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ('project', '0002_initial'), - ('track', '0001_initial'), + ("project", "0002_initial"), + ("track", "0001_initial"), ] operations = [ migrations.AddField( - model_name='timetrack', - name='user', - field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='+', to=settings.AUTH_USER_MODEL), + model_name="timetrack", + name="user", + field=models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, related_name="+", to=settings.AUTH_USER_MODEL + ), ), migrations.AddField( - model_name='task', - name='contract', - field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='tasks', to='track.contract'), + model_name="task", + name="contract", + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name="tasks", to="track.contract"), ), migrations.AddField( - model_name='task', - name='created_by', - field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='%(class)s_created', to=settings.AUTH_USER_MODEL), + model_name="task", + name="created_by", + field=models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, related_name="%(class)s_created", to=settings.AUTH_USER_MODEL + ), ), migrations.AddField( - model_name='task', - name='modified_by', - field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='%(class)s_modified', to=settings.AUTH_USER_MODEL), + model_name="task", + name="modified_by", + field=models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, related_name="%(class)s_modified", to=settings.AUTH_USER_MODEL + ), ), migrations.AddField( - model_name='contract', - name='created_by', - field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='%(class)s_created', to=settings.AUTH_USER_MODEL), + model_name="contract", + name="created_by", + field=models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, related_name="%(class)s_created", to=settings.AUTH_USER_MODEL + ), ), migrations.AddField( - model_name='contract', - name='modified_by', - field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='%(class)s_modified', to=settings.AUTH_USER_MODEL), + model_name="contract", + name="modified_by", + field=models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, related_name="%(class)s_modified", to=settings.AUTH_USER_MODEL + ), ), migrations.AddField( - model_name='contract', - name='project', - field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='contracts', to='project.project'), + model_name="contract", + name="project", + field=models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, related_name="contracts", to="project.project" + ), ), ] diff --git a/apps/track/migrations/0003_contract_total_estimated_hours_task_estimated_hours_and_more.py b/apps/track/migrations/0003_contract_total_estimated_hours_task_estimated_hours_and_more.py index 1f140e3..c7f023b 100644 --- a/apps/track/migrations/0003_contract_total_estimated_hours_task_estimated_hours_and_more.py +++ b/apps/track/migrations/0003_contract_total_estimated_hours_task_estimated_hours_and_more.py @@ -6,23 +6,33 @@ class Migration(migrations.Migration): dependencies = [ - ('track', '0002_initial'), + ("track", "0002_initial"), ] operations = [ migrations.AddField( - model_name='contract', - name='total_estimated_hours', + model_name="contract", + name="total_estimated_hours", field=models.FloatField(blank=True, null=True), ), migrations.AddField( - model_name='task', - name='estimated_hours', + model_name="task", + name="estimated_hours", field=models.FloatField(blank=True, null=True), ), migrations.AlterField( - model_name='timetrack', - name='task_type', - field=models.PositiveSmallIntegerField(choices=[(1000, 'Design'), (1100, 'Development'), (1200, 'DevOps'), (2000, 'Documentation'), (3000, 'Documentation'), (4000, 'Meeting'), (5000, 'QA')]), + model_name="timetrack", + name="task_type", + field=models.PositiveSmallIntegerField( + choices=[ + (1000, "Design"), + (1100, "Development"), + (1200, "DevOps"), + (2000, "Documentation"), + (3000, "Documentation"), + (4000, "Meeting"), + (5000, "QA"), + ] + ), ), ] diff --git a/apps/user/migrations/0001_initial.py b/apps/user/migrations/0001_initial.py index dd19b22..33ccbb1 100644 --- a/apps/user/migrations/0001_initial.py +++ b/apps/user/migrations/0001_initial.py @@ -1,7 +1,7 @@ # Generated by Django 4.2.13 on 2024-06-18 10:58 -from django.db import migrations, models import django.utils.timezone +from django.db import migrations, models class Migration(migrations.Migration): @@ -9,32 +9,76 @@ class Migration(migrations.Migration): initial = True dependencies = [ - ('auth', '0012_alter_user_first_name_max_length'), + ("auth", "0012_alter_user_first_name_max_length"), ] operations = [ migrations.CreateModel( - name='User', + name="User", fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('password', models.CharField(max_length=128, verbose_name='password')), - ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), - ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')), - ('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')), - ('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')), - ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')), - ('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')), - ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')), - ('email', models.EmailField(max_length=254, unique=True)), - ('invalid_email', models.BooleanField(default=False, help_text='Is Bounced email?')), - ('display_name', models.CharField(blank=True, max_length=255, verbose_name='system generated user display name')), - ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')), - ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')), + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("password", models.CharField(max_length=128, verbose_name="password")), + ("last_login", models.DateTimeField(blank=True, null=True, verbose_name="last login")), + ( + "is_superuser", + models.BooleanField( + default=False, + help_text="Designates that this user has all permissions without explicitly assigning them.", + verbose_name="superuser status", + ), + ), + ("first_name", models.CharField(blank=True, max_length=150, verbose_name="first name")), + ("last_name", models.CharField(blank=True, max_length=150, verbose_name="last name")), + ( + "is_staff", + models.BooleanField( + default=False, + help_text="Designates whether the user can log into this admin site.", + verbose_name="staff status", + ), + ), + ( + "is_active", + models.BooleanField( + default=True, + help_text="Designates whether this user should be treated as active. Unselect this instead of deleting accounts.", + verbose_name="active", + ), + ), + ("date_joined", models.DateTimeField(default=django.utils.timezone.now, verbose_name="date joined")), + ("email", models.EmailField(max_length=254, unique=True)), + ("invalid_email", models.BooleanField(default=False, help_text="Is Bounced email?")), + ( + "display_name", + models.CharField(blank=True, max_length=255, verbose_name="system generated user display name"), + ), + ( + "groups", + models.ManyToManyField( + blank=True, + help_text="The groups this user belongs to. A user will get all permissions granted to each of their groups.", + related_name="user_set", + related_query_name="user", + to="auth.group", + verbose_name="groups", + ), + ), + ( + "user_permissions", + models.ManyToManyField( + blank=True, + help_text="Specific permissions for this user.", + related_name="user_set", + related_query_name="user", + to="auth.permission", + verbose_name="user permissions", + ), + ), ], options={ - 'verbose_name': 'user', - 'verbose_name_plural': 'users', - 'abstract': False, + "verbose_name": "user", + "verbose_name_plural": "users", + "abstract": False, }, ), ] diff --git a/apps/user/migrations/0002_user_department.py b/apps/user/migrations/0002_user_department.py index efbd337..2e8568c 100644 --- a/apps/user/migrations/0002_user_department.py +++ b/apps/user/migrations/0002_user_department.py @@ -6,14 +6,23 @@ class Migration(migrations.Migration): dependencies = [ - ('user', '0001_initial'), + ("user", "0001_initial"), ] operations = [ migrations.AddField( - model_name='user', - name='department', - field=models.PositiveSmallIntegerField(choices=[(1000, 'Data Analyst'), (1100, 'Development'), (1200, 'Development'), (2000, "Management"), (3000, 'Project Manager'), (5000, 'QA')]), + model_name="user", + name="department", + field=models.PositiveSmallIntegerField( + choices=[ + (1000, "Data Analyst"), + (1100, "Development"), + (1200, "Development"), + (2000, "Management"), + (3000, "Project Manager"), + (5000, "QA"), + ] + ), preserve_default=False, ), ] diff --git a/apps/user/migrations/0003_alter_user_department.py b/apps/user/migrations/0003_alter_user_department.py index bf6ac51..8302e0d 100644 --- a/apps/user/migrations/0003_alter_user_department.py +++ b/apps/user/migrations/0003_alter_user_department.py @@ -6,13 +6,23 @@ class Migration(migrations.Migration): dependencies = [ - ('user', '0002_user_department'), + ("user", "0002_user_department"), ] operations = [ migrations.AlterField( - model_name='user', - name='department', - field=models.PositiveSmallIntegerField(choices=[(1000, 'Data Analyst'), (1100, 'Development'), (1200, 'Development'), (2000, 'Management'), (3000, 'Project Manager'), (5000, 'QA')], null=True), + model_name="user", + name="department", + field=models.PositiveSmallIntegerField( + choices=[ + (1000, "Data Analyst"), + (1100, "Development"), + (1200, "Development"), + (2000, "Management"), + (3000, "Project Manager"), + (5000, "QA"), + ], + null=True, + ), ), ] From 895f64c24b9239806104acdd6d79ae1b3c03e68f Mon Sep 17 00:00:00 2001 From: thenav56 Date: Mon, 5 Aug 2024 17:19:52 +0545 Subject: [PATCH 03/51] Add bulk mutations for time tracks - Use strawberry.interface for mixin types - Fix formating issues - Fix deprecation with graphql_ide - Remove not-required settings.py configs - Fix user save (remove force_insert after manual save) --- apps/common/types.py | 3 + apps/common/views.py | 28 +- apps/project/factories.py | 25 ++ apps/track/factories.py | 23 ++ .../migrations/0004_timetrack_start_time.py | 18 + apps/track/models.py | 2 + apps/track/mutations.py | 26 +- apps/track/queries.py | 11 +- apps/track/serializers.py | 13 + apps/track/tests/__init__.py | 0 apps/track/tests/test_mutations.py | 374 ++++++++++++++++++ apps/track/tests/test_queries.py | 253 ++++++++++++ apps/track/types.py | 1 + apps/user/factories.py | 22 ++ apps/user/models.py | 2 + apps/user/queries.py | 3 +- apps/user/tests/__init__.py | 0 apps/user/tests/test_queries.py | 47 +++ main/settings.py | 2 - main/tests.py | 185 +++++++++ main/urls.py | 2 +- utils/strawberry/mutations.py | 62 ++- 22 files changed, 1074 insertions(+), 28 deletions(-) create mode 100644 apps/project/factories.py create mode 100644 apps/track/factories.py create mode 100644 apps/track/migrations/0004_timetrack_start_time.py create mode 100644 apps/track/tests/__init__.py create mode 100644 apps/track/tests/test_mutations.py create mode 100644 apps/track/tests/test_queries.py create mode 100644 apps/user/factories.py create mode 100644 apps/user/tests/__init__.py create mode 100644 apps/user/tests/test_queries.py create mode 100644 main/tests.py diff --git a/apps/common/types.py b/apps/common/types.py index e78fd53..7d43fef 100644 --- a/apps/common/types.py +++ b/apps/common/types.py @@ -12,6 +12,7 @@ from .models import UserResource +@strawberry.interface class UserResourceTypeMixin: created_at: datetime.datetime modified_at: datetime.datetime @@ -25,7 +26,9 @@ async def modified_by(self, root: UserResource, info: Info) -> UserType: return await info.context.dl.user.load_user.load(root.modified_by_id) +@strawberry.interface class ClientIdMixin: + @strawberry_django.field def client_id(self, root: models.Model, info: Info) -> strawberry.ID: # NOTE: We should always provide non-null client_id diff --git a/apps/common/views.py b/apps/common/views.py index 3d41911..55c3fa5 100644 --- a/apps/common/views.py +++ b/apps/common/views.py @@ -1,10 +1,10 @@ -from django.http import HttpResponse -from django.shortcuts import render, redirect -from django.views.decorators.csrf import csrf_exempt from django.conf import settings from django.contrib.auth import login -from google.oauth2 import id_token +from django.http import HttpResponse +from django.shortcuts import redirect, render +from django.views.decorators.csrf import csrf_exempt from google.auth.transport import requests +from google.oauth2 import id_token from apps.user.models import User @@ -16,7 +16,7 @@ def dev_sign_in(request): """ return render( request, - 'common/sign_in.html', + "common/sign_in.html", context=dict( GOOGLE_OAUTH_CLIENT_ID=settings.GOOGLE_OAUTH_CLIENT_ID, GOOGLE_OAUTH_REDIRECT_URL=settings.GOOGLE_OAUTH_REDIRECT_URL, @@ -29,12 +29,10 @@ def google_oauth(request): """ Google calls this URL after the user has signed in with their Google account. """ - token = request.POST['credential'] + token = request.POST["credential"] try: - user_data = id_token.verify_oauth2_token( - token, requests.Request(), settings.GOOGLE_OAUTH_CLIENT_ID - ) + user_data = id_token.verify_oauth2_token(token, requests.Request(), settings.GOOGLE_OAUTH_CLIENT_ID) """ { 'hd': 'togglecorp.com', @@ -50,18 +48,18 @@ def google_oauth(request): except ValueError: return HttpResponse(status=403) - email = user_data['email'].lowercase() + email = user_data["email"].lower() if user := User.objects.filter(email=email).first(): - user.first_name = user_data['given_name'] - user.last_name = user_data['family_name'] + user.first_name = user_data["given_name"] + user.last_name = user_data["family_name"] # TODO: User picture? - user.save(update_fields=('first_name', 'last_name')) + user.save(update_fields=("first_name", "last_name", "display_name")) login(request, user) else: new_user = User.objects.create( email=email, - first_name=user_data['given_name'], - last_name=user_data['family_name'], + first_name=user_data["given_name"], + last_name=user_data["family_name"], ) login(request, new_user) diff --git a/apps/project/factories.py b/apps/project/factories.py new file mode 100644 index 0000000..40eb92e --- /dev/null +++ b/apps/project/factories.py @@ -0,0 +1,25 @@ +import factory +from factory.django import DjangoModelFactory + +from .models import Client, Contractor, Project + + +class ClientFactory(DjangoModelFactory): + name = factory.Sequence(lambda n: f"Client-{n}") + + class Meta: # type: ignore[reportIncompatibleVariab] + model = Client + + +class ContractorFactory(DjangoModelFactory): + name = factory.Sequence(lambda n: f"Contractor-{n}") + + class Meta: # type: ignore[reportIncompatibleVariab] + model = Contractor + + +class ProjectFactory(DjangoModelFactory): + name = factory.Sequence(lambda n: f"Project-{n}") + + class Meta: # type: ignore[reportIncompatibleVariab] + model = Project diff --git a/apps/track/factories.py b/apps/track/factories.py new file mode 100644 index 0000000..18cbfdf --- /dev/null +++ b/apps/track/factories.py @@ -0,0 +1,23 @@ +import factory +from factory.django import DjangoModelFactory + +from .models import Contract, Task, TimeTrack + + +class ContractFactory(DjangoModelFactory): + name = factory.Sequence(lambda n: f"Contract-{n}") + + class Meta: # type: ignore[reportIncompatibleVariab] + model = Contract + + +class TaskFactory(DjangoModelFactory): + name = factory.Sequence(lambda n: f"Task-{n}") + + class Meta: # type: ignore[reportIncompatibleVariab] + model = Task + + +class TimeTrackFactory(DjangoModelFactory): + class Meta: # type: ignore[reportIncompatibleVariab] + model = TimeTrack diff --git a/apps/track/migrations/0004_timetrack_start_time.py b/apps/track/migrations/0004_timetrack_start_time.py new file mode 100644 index 0000000..4d1635e --- /dev/null +++ b/apps/track/migrations/0004_timetrack_start_time.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.14 on 2024-08-04 04:54 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("track", "0003_contract_total_estimated_hours_task_estimated_hours_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="timetrack", + name="start_time", + field=models.TimeField(blank=True, null=True), + ), + ] diff --git a/apps/track/models.py b/apps/track/models.py index 92ae65f..dc743c0 100644 --- a/apps/track/models.py +++ b/apps/track/models.py @@ -47,6 +47,8 @@ class TaskType(models.IntegerChoices): date = models.DateField() task_type = models.PositiveSmallIntegerField(choices=TaskType.choices) + + start_time = models.TimeField(null=True, blank=True) description = models.TextField(blank=True) is_done = models.BooleanField(default=False) diff --git a/apps/track/mutations.py b/apps/track/mutations.py index 804f31b..8c94868 100644 --- a/apps/track/mutations.py +++ b/apps/track/mutations.py @@ -2,12 +2,17 @@ from main.graphql.context import Info from utils.common import get_object_or_404_async -from utils.strawberry.mutations import ModelMutation, MutationResponseType +from utils.strawberry.mutations import ( + BulkMutationResponseType, + ModelMutation, + MutationResponseType, +) -from .serializers import TimeTrackSerializer +from .serializers import TimeTrackBulkSerializer, TimeTrackSerializer from .types import TimeTrackType TimeTrackMutation = ModelMutation("TimeTrack", TimeTrackSerializer) +TimeTrackBulkMutation = ModelMutation("TimeTrackBulk", TimeTrackBulkSerializer) @strawberry.type @@ -23,9 +28,26 @@ async def create_time_track( @strawberry.mutation async def update_time_track( self, + id: strawberry.ID, data: TimeTrackMutation.PartialInputType, # type: ignore[reportInvalidTypeForm] info: Info, ) -> MutationResponseType[TimeTrackType]: queryset = TimeTrackType.get_queryset(None, None, info).filter(user=info.context.request.user) instance = await get_object_or_404_async(queryset, id=id) return await TimeTrackMutation.handle_update_mutation(data, info, None, instance) + + @strawberry.mutation + async def bulk_time_track( + self, + info: Info, + items: list[TimeTrackBulkMutation.InputType] | None = [], # type: ignore[reportInvalidTypeForm] + delete_ids: list[strawberry.ID] | None = [], + ) -> BulkMutationResponseType[TimeTrackType]: + queryset = TimeTrackType.get_queryset(None, None, info).filter(user=info.context.request.user) + return await TimeTrackBulkMutation.handle_bulk_mutation( + queryset, + items, + delete_ids, + info, + None, + ) diff --git a/apps/track/queries.py b/apps/track/queries.py index 84fc090..750c14e 100644 --- a/apps/track/queries.py +++ b/apps/track/queries.py @@ -34,12 +34,13 @@ class PrivateQuery: # Unbounded ---------------------------- @strawberry_django.field(description="Return all UnArchived contracts") - async def all_contracts(self, info: Info) -> list[ContractType]: - return [contract async for contract in ContractType.get_queryset(None, None, info).filter(is_archived=False)] + async def all_active_contracts(self, info: Info) -> list[ContractType]: + qs = ContractType.get_queryset(None, None, info).filter(is_archived=False).order_by("-id") + return [contract async for contract in qs] @strawberry_django.field(description="Return all UnArchived tasks") - async def all_tasks(self, info: Info) -> list[TaskType]: - qs = TaskType.get_queryset(None, None, info).filter(is_archived=False, contract__is_archived=False) + async def all_active_tasks(self, info: Info) -> list[TaskType]: + qs = TaskType.get_queryset(None, None, info).filter(is_archived=False, contract__is_archived=False).order_by("-id") return [task async for task in qs] @strawberry_django.field @@ -50,7 +51,7 @@ async def my_time_tracks(self, info: Info, date: datetime.date) -> list[TimeTrac date=date, user=info.context.request.user, ) - .all() + .order_by("-id") ) return [time_track async for time_track in qs] diff --git a/apps/track/serializers.py b/apps/track/serializers.py index 89d87dc..a110910 100644 --- a/apps/track/serializers.py +++ b/apps/track/serializers.py @@ -1,6 +1,7 @@ from rest_framework import serializers from apps.common.serializers import TempClientIdMixin +from utils.strawberry.serializers import IntegerIDField from .models import TimeTrack @@ -15,9 +16,21 @@ class Meta: # type: ignore[reportIncompatibleVariab] "description", "is_done", "duration", + "start_time", "client_id", ) def create(self, validated_data): validated_data["user"] = self.context["request"].user return super().create(validated_data) + + +class TimeTrackBulkSerializer(TimeTrackSerializer): + # Required by mutation + id = IntegerIDField(required=False) + + class Meta(TimeTrackSerializer.Meta): + fields = ( + "id", + *TimeTrackSerializer.Meta.fields, + ) diff --git a/apps/track/tests/__init__.py b/apps/track/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/track/tests/test_mutations.py b/apps/track/tests/test_mutations.py new file mode 100644 index 0000000..9e289f5 --- /dev/null +++ b/apps/track/tests/test_mutations.py @@ -0,0 +1,374 @@ +from apps.project.factories import ClientFactory, ContractorFactory, ProjectFactory +from apps.track.factories import ContractFactory, TaskFactory, TimeTrackFactory +from apps.track.models import TimeTrack +from apps.user.factories import UserFactory +from main.tests import TestCase + + +class TestTrackBulkMutation(TestCase): + class Mutation: + BULK_TIME_TRACK = """ + fragment TimeTrackTypeResponse on TimeTrackType { + id + clientId + userId + date + taskId + taskType + isDone + duration + description + startTime + } + + mutation MyMutation( + $deleteIds: [ID!], + $items: [TimeTrackBulkCreateInput!], + ) { + private { + bulkTimeTrack(items: $items, deleteIds: $deleteIds) { + errors + results { + ...TimeTrackTypeResponse + } + deleted { + ...TimeTrackTypeResponse + } + } + } + } + """ + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.user = UserFactory.create() + cls.user_02 = UserFactory.create() + + cls.ur_kwargs = dict(created_by=cls.user, modified_by=cls.user) + cls.client = ClientFactory.create(**cls.ur_kwargs) + cls.contractor = ContractorFactory.create(**cls.ur_kwargs) + + cls.project = ProjectFactory.create( + client=cls.client, + contractor=cls.contractor, + **cls.ur_kwargs, + ) + + # Contracts + cls.active_contract = ContractFactory.create(project=cls.project, **cls.ur_kwargs) + cls.archived_contract = ContractFactory.create(project=cls.project, is_archived=True, **cls.ur_kwargs) + + # Tasks + cls.active_tasks = TaskFactory.create_batch( + 5, + **cls.ur_kwargs, + contract=cls.active_contract, + estimated_hours=10, + ) + cls.archived_tasks = TaskFactory.create_batch( + 5, + **cls.ur_kwargs, + contract=cls.active_contract, + is_archived=True, + estimated_hours=20, + ) + + cls.common_time_track_kwargs = dict( + task=cls.active_tasks[1], + date="2021-01-02", + task_type=TimeTrack.TaskType.DEVELOPMENT, + description="Norm description", + is_done=False, + duration="00:40", + start_time="09:30:00", + ) + + def _query(self, _data, **kwargs): + return self.query_check( + self.Mutation.BULK_TIME_TRACK, + variables=_data, + **kwargs, + ) + + def _get_ids(self, items): + return [item.pk for item in items] + + def test_bulk_time_track_unauthenticated(self): + # Without authentication ----- + content = self._query({}, assert_errors=True) + assert content["data"] is None + + def test_bulk_time_track_create(self): + # With authentication ----- + self.force_login(self.user) + + data = { + "items": [ + dict( + task=self.gID(self.active_tasks[0].pk), + date="2021-01-01", + taskType=self.genum(TimeTrack.TaskType.DEVELOPMENT), + description="Normal description", + isDone=True, + duration=30 * 60, + startTime="09:30:00", + clientId="client-id-01", + ), + ], + } + + content = self._query(data) + resp_data = content["data"]["private"]["bulkTimeTrack"] + assert resp_data["errors"] == [] + assert resp_data["deleted"] == [] + self.assertListDictEqual( + resp_data["results"], + [ + { + **data["items"][0], + "userId": self.gID(self.user.pk), + "taskId": self.gID(self.active_tasks[0].pk), + }, + ], + ignore_keys=["id", "task"], + ) + + def test_bulk_time_track_update(self): + # With authentication ----- + self.force_login(self.user) + time_tracks = TimeTrackFactory.create_batch(5, **self.common_time_track_kwargs, user=self.user) + + data = { + "items": [ + dict( + id=self.gID(time_tracks[0].pk), + task=self.gID(self.active_tasks[0].pk), + date="2021-01-01", + taskType=self.genum(TimeTrack.TaskType.DESIGN), + description="Normal description - 0", + isDone=True, + duration=30 * 60, + startTime="09:31:00", + clientId="client-id-01", + ), + dict( + id=self.gID(time_tracks[1].pk), + task=self.gID(self.active_tasks[0].pk), + date="2021-01-02", + taskType=self.genum(TimeTrack.TaskType.DEV_OPS), + description="Normal description - 1", + duration=30 * 60, + startTime="09:32:00", + clientId="client-id-02", + ), + dict( + id=self.gID(time_tracks[2].pk), + task=self.gID(self.active_tasks[0].pk), + taskType=self.genum(TimeTrack.TaskType.DEV_OPS), + date="2021-01-02", + description="Normal description - 2", + clientId="client-id-03", + ), + ], + } + + default_time_track_kwargs = { + "date": self.common_time_track_kwargs["date"], + "isDone": self.common_time_track_kwargs["is_done"], + "startTime": self.common_time_track_kwargs["start_time"], + "duration": 40 * 60, # self.common_time_track_kwargs["duration"] + "userId": self.gID(self.user.pk), + "taskId": self.gID(self.active_tasks[0].pk), + } + + content = self._query(data) + resp_data = content["data"]["private"]["bulkTimeTrack"] + assert resp_data["errors"] == [] + assert resp_data["deleted"] == [] + self.assertListDictEqual( + resp_data["results"], + [ + { + **default_time_track_kwargs, + **item, + } + for item in data["items"][:3] + ], + ignore_keys=["task"], + ) + + def test_bulk_time_track_delete(self): + # With authentication ----- + self.force_login(self.user) + time_tracks = TimeTrackFactory.create_batch(5, **self.common_time_track_kwargs, user=self.user) + others_time_tracks = TimeTrackFactory.create_batch(5, **self.common_time_track_kwargs, user=self.user_02) + + try_to_deleted = [*time_tracks[:4], *others_time_tracks] + needs_to_be_deleted = time_tracks[:4] + needs_to_be_preserved = [*time_tracks[4:], *others_time_tracks] + data = { + "deleteIds": [time_track.pk for time_track in try_to_deleted], + } + + content = self._query(data) + resp_data = content["data"]["private"]["bulkTimeTrack"] + assert resp_data["errors"] == [] + assert resp_data["results"] == [] + self.assertListDictEqual( + resp_data["deleted"], + [ + { + "id": self.gID(time_track.pk), + } + for time_track in needs_to_be_deleted + ], + include_keys=["id"], + ) + + current_time_track_ids = set(TimeTrack.objects.values_list("id", flat=True)) + + assert current_time_track_ids.isdisjoint( + set([i.pk for i in needs_to_be_deleted]) + ), "Most of the user's time_track should be deleted" + + assert set(self._get_ids(needs_to_be_preserved)).issubset( + current_time_track_ids + ), "All other user's time_track should't be deleted" + + def test_bulk_time_track_mix(self): + """ + This is mix of all of the above cases. + NOTE: Will have duplicate piece of code + """ + # With authentication ----- + self.force_login(self.user) + time_tracks = TimeTrackFactory.create_batch(5, **self.common_time_track_kwargs, user=self.user) + others_time_tracks = TimeTrackFactory.create_batch(5, **self.common_time_track_kwargs, user=self.user_02) + + # From test_bulk_time_track_delete + try_to_deleted = [*time_tracks[2:], *others_time_tracks] + needs_to_be_deleted = time_tracks[2:] + needs_to_be_preserved = [*time_tracks[:2], *others_time_tracks] + + data = { + # From test_bulk_time_track_delete + "deleteIds": [time_track.pk for time_track in try_to_deleted], + "items": [ + # From test_bulk_time_track_create + dict( + task=self.gID(self.active_tasks[0].pk), + date="2021-01-01", + taskType=self.genum(TimeTrack.TaskType.DEVELOPMENT), + description="Normal description - 0", + isDone=True, + duration=30 * 60, + startTime="09:30:00", + clientId="client-id-00", + ), + # From test_bulk_time_track_update + dict( + id=self.gID(time_tracks[0].pk), + task=self.gID(self.active_tasks[0].pk), + date="2021-01-01", + taskType=self.genum(TimeTrack.TaskType.DESIGN), + description="Normal description - 1", + isDone=True, + duration=30 * 60, + startTime="09:31:00", + clientId="client-id-01", + ), + dict( + id=self.gID(time_tracks[1].pk), + task=self.gID(self.active_tasks[0].pk), + date="2021-01-02", + taskType=self.genum(TimeTrack.TaskType.DEV_OPS), + description="Normal description - 2", + duration=30 * 60, + startTime="09:32:00", + clientId="client-id-02", + ), + # -- NOTE: This will be deleted and re-created + dict( + id=self.gID(time_tracks[2].pk), + task=self.gID(self.active_tasks[0].pk), + taskType=self.genum(TimeTrack.TaskType.DEV_OPS), + date="2021-01-02", + description="Normal description - 3", + clientId="client-id-03", + ), + ], + } + + # From test_bulk_time_track_update + default_time_track_kwargs = { + "date": self.common_time_track_kwargs["date"], + "isDone": self.common_time_track_kwargs["is_done"], + "startTime": self.common_time_track_kwargs["start_time"], + "duration": 40 * 60, # self.common_time_track_kwargs["duration"] + "userId": self.gID(self.user.pk), + "taskId": self.gID(self.active_tasks[0].pk), + } + + self.maxDiff = None + + content = self._query(data) + resp_data = content["data"]["private"]["bulkTimeTrack"] + assert resp_data["errors"] == [] + + # Create + self.assertListDictEqual( + [ + resp_data["results"][0], + resp_data["results"][3], + ], + [ + { + **default_time_track_kwargs, + **data["items"][0], + }, + { + **default_time_track_kwargs, + **data["items"][3], + "startTime": None, + "duration": None, + }, + ], + ignore_keys=["id", "task"], + ) + # As this is deleted the id should be new + assert resp_data["results"][3]["id"] > data["items"][3]["id"] + + # Update -- id should be same + self.assertListDictEqual( + resp_data["results"][1:2], + [ + { + **default_time_track_kwargs, + **item, + } + for item in data["items"][1:2] + ], + ignore_keys=["task"], + ) + + self.assertListDictEqual( + resp_data["deleted"], + [ + { + "id": self.gID(time_track.pk), + } + for time_track in needs_to_be_deleted + ], + include_keys=["id"], + ) + + current_time_track_ids = set(TimeTrack.objects.values_list("id", flat=True)) + + assert current_time_track_ids.isdisjoint( + set(self._get_ids(needs_to_be_deleted)) + ), "Most of the user's time_track should be deleted" + + assert set(self._get_ids(needs_to_be_preserved)).issubset( + current_time_track_ids + ), "All other user's time_track should't be deleted" diff --git a/apps/track/tests/test_queries.py b/apps/track/tests/test_queries.py new file mode 100644 index 0000000..8586ff2 --- /dev/null +++ b/apps/track/tests/test_queries.py @@ -0,0 +1,253 @@ +from apps.project.factories import ClientFactory, ContractorFactory, ProjectFactory +from apps.track.factories import ContractFactory, TaskFactory, TimeTrackFactory +from apps.track.models import TimeTrack +from apps.user.factories import UserFactory +from main.tests import TestCase + + +class TestTrackQuery(TestCase): + class Query: + ALL_ACTIVE_CONTRACTS = """ + query MyQuery { + private { + allActiveContracts { + id + isArchived + name + projectId + totalEstimatedHours + totalTasksEstimatedHours + project { + id + name + } + } + } + } + """ + + ALL_ACTIVE_TASKS = """ + query MyQuery { + private { + allActiveTasks { + id + isArchived + name + contractId + contract { + id + name + } + estimatedHours + } + } + } + """ + + MY_TIME_TRACKS = """ + query MyQuery($date: Date!) { + private { + myTimeTracks(date: $date) { + id + date + taskId + task { + id + name + } + taskType + taskTypeDisplay + userId + user { + id + displayName + } + isDone + duration + description + startTime + } + } + } + """ + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.user = UserFactory.create() + cls.user_2 = UserFactory.create() + cls.ur_kwargs = dict(created_by=cls.user, modified_by=cls.user) + cls.client = ClientFactory.create(**cls.ur_kwargs) + cls.contractor = ContractorFactory.create(**cls.ur_kwargs) + + cls.project = ProjectFactory.create( + client=cls.client, + contractor=cls.contractor, + **cls.ur_kwargs, + ) + + # Contracts + cls.active_contracts = ContractFactory.create_batch( + 5, + project=cls.project, + **cls.ur_kwargs, + ) + cls.archived_contracts = ContractFactory.create_batch( + 5, + project=cls.project, + is_archived=True, + **cls.ur_kwargs, + ) + + # Tasks + cls.active_tasks = TaskFactory.create_batch( + 5, + **cls.ur_kwargs, + contract=cls.active_contracts[0], + estimated_hours=10, + ) + cls.archived_tasks = TaskFactory.create_batch( + 5, + **cls.ur_kwargs, + contract=cls.active_contracts[0], + is_archived=True, + estimated_hours=20, + ) + + def test_active_contracts(self): + # Without authentication ----- + content = self.query_check( + self.Query.ALL_ACTIVE_CONTRACTS, + assert_errors=True, + ) + assert content["data"] is None + + # With authentication ----- + self.force_login(self.user) + content = self.query_check(self.Query.ALL_ACTIVE_CONTRACTS) + self.assertEqual( + content["data"]["private"]["allActiveContracts"], + [ + dict( + id=self.gID(contract.pk), + isArchived=contract.is_archived, + name=contract.name, + projectId=self.gID(contract.project_id), + totalEstimatedHours=contract.total_estimated_hours, + totalTasksEstimatedHours=sum( + [ + task.estimated_hours + for task in contract.tasks.all() + # if not task.is_archived + ] + ), + project=dict( + id=self.gID(contract.project.id), + name=contract.project.name, + ), + ) + for contract in self.active_contracts[::-1] + ], + ) + + def test_active_tasks(self): + # Without authentication ----- + content = self.query_check( + self.Query.ALL_ACTIVE_TASKS, + assert_errors=True, + ) + assert content["data"] is None + + # With authentication ----- + self.force_login(self.user) + content = self.query_check(self.Query.ALL_ACTIVE_TASKS) + self.assertEqual( + content["data"]["private"]["allActiveTasks"], + [ + dict( + id=self.gID(task.pk), + isArchived=task.is_archived, + name=task.name, + contractId=self.gID(task.contract_id), + estimatedHours=task.estimated_hours, + contract=dict( + id=self.gID(task.contract.id), + name=task.contract.name, + ), + ) + for task in self.active_tasks[::-1] + ], + None, + ) + + def test_my_time_tracks(self): + # Dataset + # -- MY + tasks = [ + (5, self.active_tasks[0]), + (4, self.active_tasks[1]), + (3, self.archived_tasks[0]), + (2, self.archived_tasks[1]), + ] + date = "2024-01-01" + time_entries = [] + for count, task in tasks: + common_kwargs = dict( + user=self.user, + task_type=TimeTrack.TaskType.DEVELOPMENT, + duration="00:30", + task=task, + date=date, + ) + time_entries.extend(TimeTrackFactory.create_batch(count, **common_kwargs)) + # Noise data + # -- Another date + TimeTrackFactory.create_batch(count, **{**common_kwargs, "date": "2024-01-07"}) + # -- Another user + TimeTrackFactory.create_batch(count, **{**common_kwargs, "user": self.user_2}) + + # Without authentication ----- + content = self.query_check( + self.Query.MY_TIME_TRACKS, + assert_errors=True, + ) + assert content["data"] is None + + # With authentication ----- + self.force_login(self.user) + content = self.query_check(self.Query.MY_TIME_TRACKS, variables={"date": date}) + self.maxDiff = None + self.assertEqual( + content["data"]["private"]["myTimeTracks"], + [ + dict( + id=self.gID(entry.pk), + date=date, + taskId=self.gID(entry.task_id), + task=dict( + id=self.gID(entry.task.id), + name=self.gID(entry.task.name), + ), + taskType=self.genum(entry.task_type), + taskTypeDisplay=entry.task_type.label, + userId=self.gID(self.user.id), + user=dict( + id=self.gID(self.user.id), + displayName=self.gID(self.user.display_name), + ), + isDone=False, + duration=30 * 60, + description=None, + startTime=None, + ) + for entry in time_entries[::-1] + ], + None, + ) + + # TODO: + # - Client + # - Clients + # - Contractors + # - Contractor + # - TimeTracks diff --git a/apps/track/types.py b/apps/track/types.py index 4e75905..be38598 100644 --- a/apps/track/types.py +++ b/apps/track/types.py @@ -60,6 +60,7 @@ class TimeTrackType(ClientIdMixin): user_id: strawberry.ID task_id: strawberry.ID is_done: strawberry.auto + start_time: strawberry.auto duration: TimeDuration | None task_type = enum_field(TimeTrack.task_type) diff --git a/apps/user/factories.py b/apps/user/factories.py new file mode 100644 index 0000000..abb311d --- /dev/null +++ b/apps/user/factories.py @@ -0,0 +1,22 @@ +import factory +from factory import fuzzy +from factory.django import DjangoModelFactory + +from .models import User + + +class UserFactory(DjangoModelFactory): + first_name = factory.Faker("first_name") + last_name = factory.Faker("last_name") + email = factory.Sequence(lambda n: f"{n}@xyz.com") + + class Meta: # type: ignore[reportIncompatibleVariab] + model = User + + @factory.post_generation + def password(obj, create, password, **_): + if not create: + return + password_text = password or fuzzy.FuzzyText(length=15).fuzz() + obj.set_password(password_text) # type: ignore[reportAttributeAccessIssue] + obj.password_text = password_text diff --git a/apps/user/models.py b/apps/user/models.py index d1492b6..8c9087d 100644 --- a/apps/user/models.py +++ b/apps/user/models.py @@ -38,5 +38,7 @@ def save(self, *args, **kwargs): self.email = self.email.lower() if self.pk is None: super().save(*args, **kwargs) + # Remove force_insert since we have already inserted + kwargs.pop("force_insert", None) self.display_name = self.get_full_name() or f"User#{self.pk}" return super().save(*args, **kwargs) diff --git a/apps/user/queries.py b/apps/user/queries.py index 6aed1a5..26d7072 100644 --- a/apps/user/queries.py +++ b/apps/user/queries.py @@ -35,5 +35,4 @@ def me(self, info: Info) -> UserMeType | None: @strawberry.type -class PrivateQuery: - noop: strawberry.ID = strawberry.ID("noop") +class PrivateQuery: ... diff --git a/apps/user/tests/__init__.py b/apps/user/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/user/tests/test_queries.py b/apps/user/tests/test_queries.py new file mode 100644 index 0000000..6559385 --- /dev/null +++ b/apps/user/tests/test_queries.py @@ -0,0 +1,47 @@ +from apps.user.factories import UserFactory +from main.tests import TestCase + + +class TestUserQuery(TestCase): + class Query: + ME = """ + query meQuery { + public { + me { + id + email + firstName + lastName + displayName + } + } + } + """ + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.user = UserFactory.create() + # Some other users as well + cls.users = ( + UserFactory.create(first_name="Test", last_name="Hero", email="sample@test.com"), + UserFactory.create(first_name="Example", last_name="Villain", email="sample@vil.com"), + UserFactory.create(first_name="Test", last_name="Hero"), + ) + + def test_me(self): + # Without authentication ----- + content = self.query_check(self.Query.ME) + assert content["data"]["public"]["me"] is None + + user = self.user + # With authentication ----- + self.force_login(user) + content = self.query_check(self.Query.ME) + assert content["data"]["public"]["me"] == dict( + id=self.gID(user.id), + email=user.email, + firstName=user.first_name, + lastName=user.last_name, + displayName=f"{user.first_name} {user.last_name}", + ) diff --git a/main/settings.py b/main/settings.py index 162bd21..0e1b2ca 100644 --- a/main/settings.py +++ b/main/settings.py @@ -74,7 +74,6 @@ PYTEST_XDIST_WORKER=(str, None), # EMAIL EMAIL_FROM=str, - DJANGO_ADMINS=(list, ["Admin "]), EMAIL_BACKEND=(str, ""), # SES|SMTP -> CONSOLE is used by default # -- SES Credentials - Role is preferred AWS_SES_AWS_ACCESS_KEY_ID=(str, None), @@ -377,7 +376,6 @@ # EMAIL SPECIFED_EMAIL_BACKEND = env("EMAIL_BACKEND").upper() -ADMINS = env("DJANGO_ADMINS") EMAIL_FROM = env("EMAIL_FROM") if not TESTING and SPECIFED_EMAIL_BACKEND == "SES": diff --git a/main/tests.py b/main/tests.py new file mode 100644 index 0000000..79914e7 --- /dev/null +++ b/main/tests.py @@ -0,0 +1,185 @@ +from enum import Enum +from typing import Dict + +from django.conf import settings +from django.db import models +from django.test import TestCase as BaseTestCase +from django.test import override_settings + +TEST_CACHES = { + "default": { + "BACKEND": "django_redis.cache.RedisCache", + "LOCATION": settings.TEST_DJANGO_CACHE_REDIS_URL, + "OPTIONS": { + "CLIENT_CLASS": "django_redis.client.DefaultClient", + }, + "KEY_PREFIX": "test_dj_cache-", + }, + "local-memory": { + "BACKEND": "django.core.cache.backends.locmem.LocMemCache", + }, +} + +FILE_SYSTEM_TEST_STORAGES_CONFIGS = dict( + DJANGO_USE_S3=False, + STORAGES={ + "default": { + "BACKEND": "django.core.files.storage.FileSystemStorage", + }, + "staticfiles": { + "BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage", + }, + }, +) + +S3_TEST_STORAGES_CONFIGS = dict( + DJANGO_USE_S3=True, + AWS_S3_BUCKET_STATIC="qb-static", + AWS_S3_BUCKET_MEDIA="qb-media", + AWS_S3_ACCESS_KEY_ID="FAKE-ACCESS-KEY", + AWS_S3_SECRET_ACCESS_KEY="FAKE-SECRET-KEY", + AWS_S3_ENDPOINT_URL="https://fake.s3.endpoint", + STORAGES={ + # Need to manually override here as this is auto selected on startup + "default": { + "BACKEND": "main.storages.S3MediaStorage", + }, + "staticfiles": { + "BACKEND": "main.storages.S3StaticStorage", + }, + }, +) + + +@override_settings( + DEBUG=True, + EMAIL_BACKEND="django.core.mail.backends.console.EmailBackend", + MEDIA_ROOT="rest-media-temp", + STORAGES=FILE_SYSTEM_TEST_STORAGES_CONFIGS["STORAGES"], + CACHES=TEST_CACHES, + CELERY_TASK_ALWAYS_EAGER=True, +) +class TestCase(BaseTestCase): + + def setUp(self): + from django.core.cache import cache + + # Clear all test cache + cache.clear() + super().setUp() + + def force_login(self, user): + self.client.force_login(user) + + def logout(self): + self.client.logout() + + def query_check( + self, + query: str, + assert_errors: bool = False, + variables: dict | None = None, + files: dict | None = None, + **kwargs, + ) -> Dict: + import json + + if files: + # Request type: form data + response = self.client.post( + "/graphql/", + data={ + "operations": json.dumps( + { + "query": query, + "variables": variables, + } + ), + **files, + "map": json.dumps(kwargs.pop("map")), + }, + **kwargs, + ) + else: + # Request type: json + response = self.client.post( + "/graphql/", + data={ + "query": query, + "variables": variables, + }, + content_type="application/json", + **kwargs, + ) + if assert_errors: + self.assertResponseHasErrors(response) + else: + self.assertResponseNoErrors(response) + return response.json() + + def assertResponseNoErrors(self, resp, msg=None): + """ + Assert that the call went through correctly. 200 means the syntax is ok, + if there are no `errors`, + the call was fine. + :resp HttpResponse: Response + """ + content = resp.json() + self.assertEqual(resp.status_code, 200, msg or content) + self.assertNotIn("errors", list(content.keys()), msg or content) + + def assertResponseHasErrors(self, resp, msg=None): + """ + Assert that the call was failing. Take care: Even with errors, + GraphQL returns status 200! + :resp HttpResponse: Response + """ + content = resp.json() + self.assertIn("errors", list(content.keys()), msg or content) + + def genum(self, _enum: models.TextChoices | models.IntegerChoices | Enum): + """ + Return appropriate enum value. + """ + if _enum: + return _enum.name + + def gdatetime(self, _datetime): + if _datetime: + return _datetime.isoformat() + + def gID(self, pk): + if pk: + return str(pk) + + def get_media_url(self, path): + return f"http://testserver/media/{path}" + + def _dict_with_keys( + self, + data: dict, + include_keys=None, + ignore_keys=None, + ): + # TODO: Use self.assertDictEqual instead? + if all([ignore_keys, include_keys]): + raise Exception("Please use one of the options among include_keys, ignore_keys") + return { + key: value + for key, value in data.items() + if ((ignore_keys is not None and key not in ignore_keys) or (include_keys is not None and key in include_keys)) + } + + def assertListDictEqual( + self, + left, + right, + messages=None, + ignore_keys: list[str] | None = None, + include_keys: list[str] | None = None, + ): + self.assertEqual( + [self._dict_with_keys(item, ignore_keys=ignore_keys, include_keys=include_keys) for item in left], + [self._dict_with_keys(item, ignore_keys=ignore_keys, include_keys=include_keys) for item in right], + messages, + ) diff --git a/main/urls.py b/main/urls.py index 1c0b33d..4280786 100644 --- a/main/urls.py +++ b/main/urls.py @@ -14,7 +14,7 @@ "graphql/", CustomAsyncGraphQLView.as_view( schema=graphql_schema, - graphiql=False, + graphql_ide=False, ), ), path("o/google", google_oauth), diff --git a/utils/strawberry/mutations.py b/utils/strawberry/mutations.py index 7bd8de6..88ff422 100644 --- a/utils/strawberry/mutations.py +++ b/utils/strawberry/mutations.py @@ -310,7 +310,11 @@ def handle_delete(instance: models.Model) -> tuple[CustomErrorType | None, model return _CustomErrorType.generate_message(), None async def handle_create_mutation( - self, data, info: Info, permission, extra_context: typing.Optional[dict] = None + self, + data, + info: Info, + permission, + extra_context: typing.Optional[dict] = None, ) -> MutationResponseType: if errors := self.check_permissions(info, permission): return MutationResponseType(ok=False, errors=errors) @@ -358,3 +362,59 @@ async def handle_delete_mutation(self, instance: models.Model | None, info: Info if errors: return MutationResponseType(ok=False, errors=errors) return MutationResponseType(result=deleted_instance) + + async def handle_bulk_mutation( + self, + base_queryset: models.QuerySet, + items: list | None, + delete_ids: list[strawberry.ID] | None, + info: Info, + permission, + extra_context: typing.Optional[dict] = None, + ) -> BulkMutationResponseType: + if errors := self.check_permissions(info, permission): + return BulkMutationResponseType(errors=[errors]) + + errors = [] + + # Delete - First + deleted_instances = [] + delete_qs = base_queryset.filter(id__in=delete_ids).order_by("id") + async for item in delete_qs.all(): + _errors, _saved_instance = await self.handle_delete(item) + if _errors: + errors.append(_errors) + else: + deleted_instances.append(_saved_instance) + + # Create/Update - Then + results = [] + for data in items or []: + _data = process_input_data(data) + assert isinstance(_data, dict) + _id = _data.pop("id", None) + instance = None + if _id: + instance = await base_queryset.filter(id=_id).afirst() + partial = False + if instance: + partial = True + _errors, _saved_instance = await self.handle_mutation( + self.serializer_class, + _data, + info, + extra_context, + instance=instance, + partial=partial, + ) + if _errors: + errors.append(_errors) + else: + results.append(_saved_instance) + + return BulkMutationResponseType( + errors=errors, + # Data + results=results, + deleted=deleted_instances, + ) From 51e85c835a2f6e703f54e42fda825545d2ae94f7 Mon Sep 17 00:00:00 2001 From: thenav56 Date: Mon, 5 Aug 2024 17:33:50 +0545 Subject: [PATCH 04/51] Change TimeTrack -> TimeEntry --- apps/track/admin.py | 6 +- apps/track/enums.py | 6 +- apps/track/factories.py | 6 +- apps/track/filters.py | 10 +- .../0005_rename_timetrack_timeentry.py | 19 ++ apps/track/models.py | 2 +- apps/track/mutations.py | 36 ++-- apps/track/orders.py | 6 +- apps/track/queries.py | 18 +- apps/track/serializers.py | 12 +- apps/track/tests/test_mutations.py | 162 +++++++++--------- apps/track/tests/test_queries.py | 28 +-- apps/track/types.py | 18 +- schema.graphql | 123 +++++++++---- 14 files changed, 265 insertions(+), 187 deletions(-) create mode 100644 apps/track/migrations/0005_rename_timetrack_timeentry.py diff --git a/apps/track/admin.py b/apps/track/admin.py index da9c22c..914999b 100644 --- a/apps/track/admin.py +++ b/apps/track/admin.py @@ -5,7 +5,7 @@ from apps.common.admin import UserResourceAdmin, UserResourceTabularInline, VersionAdmin -from .models import Contract, Task, TimeTrack +from .models import Contract, Task, TimeEntry class ContractTaskInline(UserResourceTabularInline): @@ -57,8 +57,8 @@ def get_contract(self, obj): return obj.contract.name -@admin.register(TimeTrack) -class TimeTrackAdmin(admin.ModelAdmin): +@admin.register(TimeEntry) +class TimeEntryAdmin(admin.ModelAdmin): list_filter = ( "date", "task_type", diff --git a/apps/track/enums.py b/apps/track/enums.py index 5436d86..1ed2bf0 100644 --- a/apps/track/enums.py +++ b/apps/track/enums.py @@ -2,9 +2,9 @@ from utils.strawberry.enums import get_enum_name_from_django_field -from .models import TimeTrack +from .models import TimeEntry -TimeTrackTaskTypeEnum = strawberry.enum(TimeTrack.TaskType, name="TimeTrackTaskTypeEnum") +TimeEntryTaskTypeEnum = strawberry.enum(TimeEntry.TaskType, name="TimeEntryTaskTypeEnum") -enum_map = {get_enum_name_from_django_field(field): enum for field, enum in ((TimeTrack.task_type, TimeTrackTaskTypeEnum),)} +enum_map = {get_enum_name_from_django_field(field): enum for field, enum in ((TimeEntry.task_type, TimeEntryTaskTypeEnum),)} diff --git a/apps/track/factories.py b/apps/track/factories.py index 18cbfdf..af6fb0a 100644 --- a/apps/track/factories.py +++ b/apps/track/factories.py @@ -1,7 +1,7 @@ import factory from factory.django import DjangoModelFactory -from .models import Contract, Task, TimeTrack +from .models import Contract, Task, TimeEntry class ContractFactory(DjangoModelFactory): @@ -18,6 +18,6 @@ class Meta: # type: ignore[reportIncompatibleVariab] model = Task -class TimeTrackFactory(DjangoModelFactory): +class TimeEntryFactory(DjangoModelFactory): class Meta: # type: ignore[reportIncompatibleVariab] - model = TimeTrack + model = TimeEntry diff --git a/apps/track/filters.py b/apps/track/filters.py index 7db2508..b9223ed 100644 --- a/apps/track/filters.py +++ b/apps/track/filters.py @@ -2,8 +2,8 @@ import strawberry_django from django.db import models -from .enums import TimeTrackTaskTypeEnum -from .models import Contract, Task, TimeTrack +from .enums import TimeEntryTaskTypeEnum +from .models import Contract, Task, TimeEntry @strawberry_django.filters.filter(Contract, lookups=True) @@ -29,14 +29,14 @@ def project( return queryset, models.Q(**{f"{prefix}contract__project": value}) -@strawberry_django.filters.filter(TimeTrack, lookups=True) -class TimeTrackFilter: +@strawberry_django.filters.filter(TimeEntry, lookups=True) +class TimeEntryFilter: id: strawberry.auto user: strawberry.auto task: strawberry.auto date: strawberry.auto - task_types: list[TimeTrackTaskTypeEnum] # type: ignore[reportInvalidTypeForm] + task_types: list[TimeEntryTaskTypeEnum] # type: ignore[reportInvalidTypeForm] @strawberry_django.filter_field def project( diff --git a/apps/track/migrations/0005_rename_timetrack_timeentry.py b/apps/track/migrations/0005_rename_timetrack_timeentry.py new file mode 100644 index 0000000..b992f8e --- /dev/null +++ b/apps/track/migrations/0005_rename_timetrack_timeentry.py @@ -0,0 +1,19 @@ +# Generated by Django 4.2.14 on 2024-08-05 11:44 + +from django.conf import settings +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("track", "0004_timetrack_start_time"), + ] + + operations = [ + migrations.RenameModel( + old_name="TimeTrack", + new_name="TimeEntry", + ), + ] diff --git a/apps/track/models.py b/apps/track/models.py index dc743c0..9924128 100644 --- a/apps/track/models.py +++ b/apps/track/models.py @@ -31,7 +31,7 @@ def __str__(self): return self.name -class TimeTrack(models.Model): +class TimeEntry(models.Model): class TaskType(models.IntegerChoices): # Using 4 digit for future ordering support DESIGN = 1000, _("Design") diff --git a/apps/track/mutations.py b/apps/track/mutations.py index 8c94868..61fd2dd 100644 --- a/apps/track/mutations.py +++ b/apps/track/mutations.py @@ -8,43 +8,43 @@ MutationResponseType, ) -from .serializers import TimeTrackBulkSerializer, TimeTrackSerializer -from .types import TimeTrackType +from .serializers import TimeEntryBulkSerializer, TimeEntrySerializer +from .types import TimeEntryType -TimeTrackMutation = ModelMutation("TimeTrack", TimeTrackSerializer) -TimeTrackBulkMutation = ModelMutation("TimeTrackBulk", TimeTrackBulkSerializer) +TimeEntryMutation = ModelMutation("TimeEntry", TimeEntrySerializer) +TimeEntryBulkMutation = ModelMutation("TimeEntryBulk", TimeEntryBulkSerializer) @strawberry.type class PrivateMutation: @strawberry.mutation - async def create_time_track( + async def create_time_entry( self, - data: TimeTrackMutation.InputType, # type: ignore[reportInvalidTypeForm] + data: TimeEntryMutation.InputType, # type: ignore[reportInvalidTypeForm] info: Info, - ) -> MutationResponseType[TimeTrackType]: - return await TimeTrackMutation.handle_create_mutation(data, info, None) + ) -> MutationResponseType[TimeEntryType]: + return await TimeEntryMutation.handle_create_mutation(data, info, None) @strawberry.mutation - async def update_time_track( + async def update_time_entry( self, id: strawberry.ID, - data: TimeTrackMutation.PartialInputType, # type: ignore[reportInvalidTypeForm] + data: TimeEntryMutation.PartialInputType, # type: ignore[reportInvalidTypeForm] info: Info, - ) -> MutationResponseType[TimeTrackType]: - queryset = TimeTrackType.get_queryset(None, None, info).filter(user=info.context.request.user) + ) -> MutationResponseType[TimeEntryType]: + queryset = TimeEntryType.get_queryset(None, None, info).filter(user=info.context.request.user) instance = await get_object_or_404_async(queryset, id=id) - return await TimeTrackMutation.handle_update_mutation(data, info, None, instance) + return await TimeEntryMutation.handle_update_mutation(data, info, None, instance) @strawberry.mutation - async def bulk_time_track( + async def bulk_time_entry( self, info: Info, - items: list[TimeTrackBulkMutation.InputType] | None = [], # type: ignore[reportInvalidTypeForm] + items: list[TimeEntryBulkMutation.InputType] | None = [], # type: ignore[reportInvalidTypeForm] delete_ids: list[strawberry.ID] | None = [], - ) -> BulkMutationResponseType[TimeTrackType]: - queryset = TimeTrackType.get_queryset(None, None, info).filter(user=info.context.request.user) - return await TimeTrackBulkMutation.handle_bulk_mutation( + ) -> BulkMutationResponseType[TimeEntryType]: + queryset = TimeEntryType.get_queryset(None, None, info).filter(user=info.context.request.user) + return await TimeEntryBulkMutation.handle_bulk_mutation( queryset, items, delete_ids, diff --git a/apps/track/orders.py b/apps/track/orders.py index 13c2e8f..eba6728 100644 --- a/apps/track/orders.py +++ b/apps/track/orders.py @@ -1,7 +1,7 @@ import strawberry import strawberry_django -from .models import Contract, Task, TimeTrack +from .models import Contract, Task, TimeEntry @strawberry_django.ordering.order(Contract) @@ -18,7 +18,7 @@ class TaskOrder: created_at: strawberry.auto -@strawberry_django.ordering.order(TimeTrack) -class TimeTrackOrder: +@strawberry_django.ordering.order(TimeEntry) +class TimeEntryOrder: id: strawberry.auto date: strawberry.auto diff --git a/apps/track/queries.py b/apps/track/queries.py index 750c14e..009caec 100644 --- a/apps/track/queries.py +++ b/apps/track/queries.py @@ -6,9 +6,9 @@ from main.graphql.context import Info from utils.strawberry.paginations import CountList, pagination_field -from .filters import ContractFilter, TaskFilter, TimeTrackFilter -from .orders import ContractOrder, TaskOrder, TimeTrackOrder -from .types import ContractType, TaskType, TimeTrackType +from .filters import ContractFilter, TaskFilter, TimeEntryFilter +from .orders import ContractOrder, TaskOrder, TimeEntryOrder +from .types import ContractType, TaskType, TimeEntryType @strawberry.type @@ -26,10 +26,10 @@ class PrivateQuery: order=TaskOrder, ) - time_tracks: CountList[TimeTrackType] = pagination_field( + time_entries: CountList[TimeEntryType] = pagination_field( pagination=True, - filters=TimeTrackFilter, - order=TimeTrackOrder, + filters=TimeEntryFilter, + order=TimeEntryOrder, ) # Unbounded ---------------------------- @@ -44,16 +44,16 @@ async def all_active_tasks(self, info: Info) -> list[TaskType]: return [task async for task in qs] @strawberry_django.field - async def my_time_tracks(self, info: Info, date: datetime.date) -> list[TimeTrackType]: + async def my_time_entries(self, info: Info, date: datetime.date) -> list[TimeEntryType]: qs = ( - TimeTrackType.get_queryset(None, None, info) + TimeEntryType.get_queryset(None, None, info) .filter( date=date, user=info.context.request.user, ) .order_by("-id") ) - return [time_track async for time_track in qs] + return [time_entry async for time_entry in qs] # Single ---------------------------- @strawberry_django.field diff --git a/apps/track/serializers.py b/apps/track/serializers.py index a110910..4371be1 100644 --- a/apps/track/serializers.py +++ b/apps/track/serializers.py @@ -3,12 +3,12 @@ from apps.common.serializers import TempClientIdMixin from utils.strawberry.serializers import IntegerIDField -from .models import TimeTrack +from .models import TimeEntry -class TimeTrackSerializer(TempClientIdMixin, serializers.ModelSerializer): +class TimeEntrySerializer(TempClientIdMixin, serializers.ModelSerializer): class Meta: # type: ignore[reportIncompatibleVariab] - model = TimeTrack + model = TimeEntry fields = ( "task", "date", @@ -25,12 +25,12 @@ def create(self, validated_data): return super().create(validated_data) -class TimeTrackBulkSerializer(TimeTrackSerializer): +class TimeEntryBulkSerializer(TimeEntrySerializer): # Required by mutation id = IntegerIDField(required=False) - class Meta(TimeTrackSerializer.Meta): + class Meta(TimeEntrySerializer.Meta): fields = ( "id", - *TimeTrackSerializer.Meta.fields, + *TimeEntrySerializer.Meta.fields, ) diff --git a/apps/track/tests/test_mutations.py b/apps/track/tests/test_mutations.py index 9e289f5..626df07 100644 --- a/apps/track/tests/test_mutations.py +++ b/apps/track/tests/test_mutations.py @@ -1,14 +1,14 @@ from apps.project.factories import ClientFactory, ContractorFactory, ProjectFactory -from apps.track.factories import ContractFactory, TaskFactory, TimeTrackFactory -from apps.track.models import TimeTrack +from apps.track.factories import ContractFactory, TaskFactory, TimeEntryFactory +from apps.track.models import TimeEntry from apps.user.factories import UserFactory from main.tests import TestCase -class TestTrackBulkMutation(TestCase): +class TestEntryBulkMutation(TestCase): class Mutation: - BULK_TIME_TRACK = """ - fragment TimeTrackTypeResponse on TimeTrackType { + BULK_TIME_ENTRY = """ + fragment TimeEntryTypeResponse on TimeEntryType { id clientId userId @@ -23,16 +23,16 @@ class Mutation: mutation MyMutation( $deleteIds: [ID!], - $items: [TimeTrackBulkCreateInput!], + $items: [TimeEntryBulkCreateInput!], ) { private { - bulkTimeTrack(items: $items, deleteIds: $deleteIds) { + bulkTimeEntry(items: $items, deleteIds: $deleteIds) { errors results { - ...TimeTrackTypeResponse + ...TimeEntryTypeResponse } deleted { - ...TimeTrackTypeResponse + ...TimeEntryTypeResponse } } } @@ -74,10 +74,10 @@ def setUpClass(cls): estimated_hours=20, ) - cls.common_time_track_kwargs = dict( + cls.common_time_entry_kwargs = dict( task=cls.active_tasks[1], date="2021-01-02", - task_type=TimeTrack.TaskType.DEVELOPMENT, + task_type=TimeEntry.TaskType.DEVELOPMENT, description="Norm description", is_done=False, duration="00:40", @@ -86,7 +86,7 @@ def setUpClass(cls): def _query(self, _data, **kwargs): return self.query_check( - self.Mutation.BULK_TIME_TRACK, + self.Mutation.BULK_TIME_ENTRY, variables=_data, **kwargs, ) @@ -94,12 +94,12 @@ def _query(self, _data, **kwargs): def _get_ids(self, items): return [item.pk for item in items] - def test_bulk_time_track_unauthenticated(self): + def test_bulk_time_entry_unauthenticated(self): # Without authentication ----- content = self._query({}, assert_errors=True) assert content["data"] is None - def test_bulk_time_track_create(self): + def test_bulk_time_entry_create(self): # With authentication ----- self.force_login(self.user) @@ -108,7 +108,7 @@ def test_bulk_time_track_create(self): dict( task=self.gID(self.active_tasks[0].pk), date="2021-01-01", - taskType=self.genum(TimeTrack.TaskType.DEVELOPMENT), + taskType=self.genum(TimeEntry.TaskType.DEVELOPMENT), description="Normal description", isDone=True, duration=30 * 60, @@ -119,7 +119,7 @@ def test_bulk_time_track_create(self): } content = self._query(data) - resp_data = content["data"]["private"]["bulkTimeTrack"] + resp_data = content["data"]["private"]["bulkTimeEntry"] assert resp_data["errors"] == [] assert resp_data["deleted"] == [] self.assertListDictEqual( @@ -134,18 +134,18 @@ def test_bulk_time_track_create(self): ignore_keys=["id", "task"], ) - def test_bulk_time_track_update(self): + def test_bulk_time_entry_update(self): # With authentication ----- self.force_login(self.user) - time_tracks = TimeTrackFactory.create_batch(5, **self.common_time_track_kwargs, user=self.user) + time_entries = TimeEntryFactory.create_batch(5, **self.common_time_entry_kwargs, user=self.user) data = { "items": [ dict( - id=self.gID(time_tracks[0].pk), + id=self.gID(time_entries[0].pk), task=self.gID(self.active_tasks[0].pk), date="2021-01-01", - taskType=self.genum(TimeTrack.TaskType.DESIGN), + taskType=self.genum(TimeEntry.TaskType.DESIGN), description="Normal description - 0", isDone=True, duration=30 * 60, @@ -153,19 +153,19 @@ def test_bulk_time_track_update(self): clientId="client-id-01", ), dict( - id=self.gID(time_tracks[1].pk), + id=self.gID(time_entries[1].pk), task=self.gID(self.active_tasks[0].pk), date="2021-01-02", - taskType=self.genum(TimeTrack.TaskType.DEV_OPS), + taskType=self.genum(TimeEntry.TaskType.DEV_OPS), description="Normal description - 1", duration=30 * 60, startTime="09:32:00", clientId="client-id-02", ), dict( - id=self.gID(time_tracks[2].pk), + id=self.gID(time_entries[2].pk), task=self.gID(self.active_tasks[0].pk), - taskType=self.genum(TimeTrack.TaskType.DEV_OPS), + taskType=self.genum(TimeEntry.TaskType.DEV_OPS), date="2021-01-02", description="Normal description - 2", clientId="client-id-03", @@ -173,24 +173,24 @@ def test_bulk_time_track_update(self): ], } - default_time_track_kwargs = { - "date": self.common_time_track_kwargs["date"], - "isDone": self.common_time_track_kwargs["is_done"], - "startTime": self.common_time_track_kwargs["start_time"], - "duration": 40 * 60, # self.common_time_track_kwargs["duration"] + default_time_entry_kwargs = { + "date": self.common_time_entry_kwargs["date"], + "isDone": self.common_time_entry_kwargs["is_done"], + "startTime": self.common_time_entry_kwargs["start_time"], + "duration": 40 * 60, # self.common_time_entry_kwargs["duration"] "userId": self.gID(self.user.pk), "taskId": self.gID(self.active_tasks[0].pk), } content = self._query(data) - resp_data = content["data"]["private"]["bulkTimeTrack"] + resp_data = content["data"]["private"]["bulkTimeEntry"] assert resp_data["errors"] == [] assert resp_data["deleted"] == [] self.assertListDictEqual( resp_data["results"], [ { - **default_time_track_kwargs, + **default_time_entry_kwargs, **item, } for item in data["items"][:3] @@ -198,80 +198,80 @@ def test_bulk_time_track_update(self): ignore_keys=["task"], ) - def test_bulk_time_track_delete(self): + def test_bulk_time_entry_delete(self): # With authentication ----- self.force_login(self.user) - time_tracks = TimeTrackFactory.create_batch(5, **self.common_time_track_kwargs, user=self.user) - others_time_tracks = TimeTrackFactory.create_batch(5, **self.common_time_track_kwargs, user=self.user_02) + time_entries = TimeEntryFactory.create_batch(5, **self.common_time_entry_kwargs, user=self.user) + others_time_entries = TimeEntryFactory.create_batch(5, **self.common_time_entry_kwargs, user=self.user_02) - try_to_deleted = [*time_tracks[:4], *others_time_tracks] - needs_to_be_deleted = time_tracks[:4] - needs_to_be_preserved = [*time_tracks[4:], *others_time_tracks] + try_to_deleted = [*time_entries[:4], *others_time_entries] + needs_to_be_deleted = time_entries[:4] + needs_to_be_preserved = [*time_entries[4:], *others_time_entries] data = { - "deleteIds": [time_track.pk for time_track in try_to_deleted], + "deleteIds": [time_entry.pk for time_entry in try_to_deleted], } content = self._query(data) - resp_data = content["data"]["private"]["bulkTimeTrack"] + resp_data = content["data"]["private"]["bulkTimeEntry"] assert resp_data["errors"] == [] assert resp_data["results"] == [] self.assertListDictEqual( resp_data["deleted"], [ { - "id": self.gID(time_track.pk), + "id": self.gID(time_entry.pk), } - for time_track in needs_to_be_deleted + for time_entry in needs_to_be_deleted ], include_keys=["id"], ) - current_time_track_ids = set(TimeTrack.objects.values_list("id", flat=True)) + current_time_entry_ids = set(TimeEntry.objects.values_list("id", flat=True)) - assert current_time_track_ids.isdisjoint( + assert current_time_entry_ids.isdisjoint( set([i.pk for i in needs_to_be_deleted]) - ), "Most of the user's time_track should be deleted" + ), "Most of the user's time_entry should be deleted" assert set(self._get_ids(needs_to_be_preserved)).issubset( - current_time_track_ids - ), "All other user's time_track should't be deleted" + current_time_entry_ids + ), "All other user's time_entry should't be deleted" - def test_bulk_time_track_mix(self): + def test_bulk_time_entry_mix(self): """ This is mix of all of the above cases. NOTE: Will have duplicate piece of code """ # With authentication ----- self.force_login(self.user) - time_tracks = TimeTrackFactory.create_batch(5, **self.common_time_track_kwargs, user=self.user) - others_time_tracks = TimeTrackFactory.create_batch(5, **self.common_time_track_kwargs, user=self.user_02) + time_entries = TimeEntryFactory.create_batch(5, **self.common_time_entry_kwargs, user=self.user) + others_time_entries = TimeEntryFactory.create_batch(5, **self.common_time_entry_kwargs, user=self.user_02) - # From test_bulk_time_track_delete - try_to_deleted = [*time_tracks[2:], *others_time_tracks] - needs_to_be_deleted = time_tracks[2:] - needs_to_be_preserved = [*time_tracks[:2], *others_time_tracks] + # From test_bulk_time_entry_delete + try_to_deleted = [*time_entries[2:], *others_time_entries] + needs_to_be_deleted = time_entries[2:] + needs_to_be_preserved = [*time_entries[:2], *others_time_entries] data = { - # From test_bulk_time_track_delete - "deleteIds": [time_track.pk for time_track in try_to_deleted], + # From test_bulk_time_entry_delete + "deleteIds": [time_entry.pk for time_entry in try_to_deleted], "items": [ - # From test_bulk_time_track_create + # From test_bulk_time_entry_create dict( task=self.gID(self.active_tasks[0].pk), date="2021-01-01", - taskType=self.genum(TimeTrack.TaskType.DEVELOPMENT), + taskType=self.genum(TimeEntry.TaskType.DEVELOPMENT), description="Normal description - 0", isDone=True, duration=30 * 60, startTime="09:30:00", clientId="client-id-00", ), - # From test_bulk_time_track_update + # From test_bulk_time_entry_update dict( - id=self.gID(time_tracks[0].pk), + id=self.gID(time_entries[0].pk), task=self.gID(self.active_tasks[0].pk), date="2021-01-01", - taskType=self.genum(TimeTrack.TaskType.DESIGN), + taskType=self.genum(TimeEntry.TaskType.DESIGN), description="Normal description - 1", isDone=True, duration=30 * 60, @@ -279,10 +279,10 @@ def test_bulk_time_track_mix(self): clientId="client-id-01", ), dict( - id=self.gID(time_tracks[1].pk), + id=self.gID(time_entries[1].pk), task=self.gID(self.active_tasks[0].pk), date="2021-01-02", - taskType=self.genum(TimeTrack.TaskType.DEV_OPS), + taskType=self.genum(TimeEntry.TaskType.DEV_OPS), description="Normal description - 2", duration=30 * 60, startTime="09:32:00", @@ -290,9 +290,9 @@ def test_bulk_time_track_mix(self): ), # -- NOTE: This will be deleted and re-created dict( - id=self.gID(time_tracks[2].pk), + id=self.gID(time_entries[2].pk), task=self.gID(self.active_tasks[0].pk), - taskType=self.genum(TimeTrack.TaskType.DEV_OPS), + taskType=self.genum(TimeEntry.TaskType.DEV_OPS), date="2021-01-02", description="Normal description - 3", clientId="client-id-03", @@ -300,12 +300,12 @@ def test_bulk_time_track_mix(self): ], } - # From test_bulk_time_track_update - default_time_track_kwargs = { - "date": self.common_time_track_kwargs["date"], - "isDone": self.common_time_track_kwargs["is_done"], - "startTime": self.common_time_track_kwargs["start_time"], - "duration": 40 * 60, # self.common_time_track_kwargs["duration"] + # From test_bulk_time_entry_update + default_time_entry_kwargs = { + "date": self.common_time_entry_kwargs["date"], + "isDone": self.common_time_entry_kwargs["is_done"], + "startTime": self.common_time_entry_kwargs["start_time"], + "duration": 40 * 60, # self.common_time_entry_kwargs["duration"] "userId": self.gID(self.user.pk), "taskId": self.gID(self.active_tasks[0].pk), } @@ -313,7 +313,7 @@ def test_bulk_time_track_mix(self): self.maxDiff = None content = self._query(data) - resp_data = content["data"]["private"]["bulkTimeTrack"] + resp_data = content["data"]["private"]["bulkTimeEntry"] assert resp_data["errors"] == [] # Create @@ -324,11 +324,11 @@ def test_bulk_time_track_mix(self): ], [ { - **default_time_track_kwargs, + **default_time_entry_kwargs, **data["items"][0], }, { - **default_time_track_kwargs, + **default_time_entry_kwargs, **data["items"][3], "startTime": None, "duration": None, @@ -344,7 +344,7 @@ def test_bulk_time_track_mix(self): resp_data["results"][1:2], [ { - **default_time_track_kwargs, + **default_time_entry_kwargs, **item, } for item in data["items"][1:2] @@ -356,19 +356,19 @@ def test_bulk_time_track_mix(self): resp_data["deleted"], [ { - "id": self.gID(time_track.pk), + "id": self.gID(time_entry.pk), } - for time_track in needs_to_be_deleted + for time_entry in needs_to_be_deleted ], include_keys=["id"], ) - current_time_track_ids = set(TimeTrack.objects.values_list("id", flat=True)) + current_time_entry_ids = set(TimeEntry.objects.values_list("id", flat=True)) - assert current_time_track_ids.isdisjoint( + assert current_time_entry_ids.isdisjoint( set(self._get_ids(needs_to_be_deleted)) - ), "Most of the user's time_track should be deleted" + ), "Most of the user's time_entry should be deleted" assert set(self._get_ids(needs_to_be_preserved)).issubset( - current_time_track_ids - ), "All other user's time_track should't be deleted" + current_time_entry_ids + ), "All other user's time_entry should't be deleted" diff --git a/apps/track/tests/test_queries.py b/apps/track/tests/test_queries.py index 8586ff2..5e4d87c 100644 --- a/apps/track/tests/test_queries.py +++ b/apps/track/tests/test_queries.py @@ -1,11 +1,11 @@ from apps.project.factories import ClientFactory, ContractorFactory, ProjectFactory -from apps.track.factories import ContractFactory, TaskFactory, TimeTrackFactory -from apps.track.models import TimeTrack +from apps.track.factories import ContractFactory, TaskFactory, TimeEntryFactory +from apps.track.models import TimeEntry from apps.user.factories import UserFactory from main.tests import TestCase -class TestTrackQuery(TestCase): +class TestEntryQuery(TestCase): class Query: ALL_ACTIVE_CONTRACTS = """ query MyQuery { @@ -44,10 +44,10 @@ class Query: } """ - MY_TIME_TRACKS = """ + MY_TIME_ENTRIES = """ query MyQuery($date: Date!) { private { - myTimeTracks(date: $date) { + myTimeEntries(date: $date) { id date taskId @@ -180,7 +180,7 @@ def test_active_tasks(self): None, ) - def test_my_time_tracks(self): + def test_my_time_entries(self): # Dataset # -- MY tasks = [ @@ -194,31 +194,31 @@ def test_my_time_tracks(self): for count, task in tasks: common_kwargs = dict( user=self.user, - task_type=TimeTrack.TaskType.DEVELOPMENT, + task_type=TimeEntry.TaskType.DEVELOPMENT, duration="00:30", task=task, date=date, ) - time_entries.extend(TimeTrackFactory.create_batch(count, **common_kwargs)) + time_entries.extend(TimeEntryFactory.create_batch(count, **common_kwargs)) # Noise data # -- Another date - TimeTrackFactory.create_batch(count, **{**common_kwargs, "date": "2024-01-07"}) + TimeEntryFactory.create_batch(count, **{**common_kwargs, "date": "2024-01-07"}) # -- Another user - TimeTrackFactory.create_batch(count, **{**common_kwargs, "user": self.user_2}) + TimeEntryFactory.create_batch(count, **{**common_kwargs, "user": self.user_2}) # Without authentication ----- content = self.query_check( - self.Query.MY_TIME_TRACKS, + self.Query.MY_TIME_ENTRIES, assert_errors=True, ) assert content["data"] is None # With authentication ----- self.force_login(self.user) - content = self.query_check(self.Query.MY_TIME_TRACKS, variables={"date": date}) + content = self.query_check(self.Query.MY_TIME_ENTRIES, variables={"date": date}) self.maxDiff = None self.assertEqual( - content["data"]["private"]["myTimeTracks"], + content["data"]["private"]["myTimeEntries"], [ dict( id=self.gID(entry.pk), @@ -250,4 +250,4 @@ def test_my_time_tracks(self): # - Clients # - Contractors # - Contractor - # - TimeTracks + # - TimeEntrys diff --git a/apps/track/types.py b/apps/track/types.py index be38598..795d390 100644 --- a/apps/track/types.py +++ b/apps/track/types.py @@ -10,7 +10,7 @@ from utils.strawberry.enums import enum_display_field, enum_field from utils.strawberry.types import TimeDuration, string_field -from .models import Contract, Task, TimeTrack +from .models import Contract, Task, TimeEntry @strawberry_django.type(Contract) @@ -53,8 +53,8 @@ async def contract(self, root: Task, info: Info) -> ContractType: return await info.context.dl.track.load_contract.load(root.contract_id) -@strawberry_django.type(TimeTrack) -class TimeTrackType(ClientIdMixin): +@strawberry_django.type(TimeEntry) +class TimeEntryType(ClientIdMixin): id: strawberry.ID date: strawberry.auto user_id: strawberry.ID @@ -63,18 +63,18 @@ class TimeTrackType(ClientIdMixin): start_time: strawberry.auto duration: TimeDuration | None - task_type = enum_field(TimeTrack.task_type) - task_type_display = enum_display_field(TimeTrack.task_type) - description = string_field(TimeTrack.description) + task_type = enum_field(TimeEntry.task_type) + task_type_display = enum_display_field(TimeEntry.task_type) + description = string_field(TimeEntry.description) @staticmethod def get_queryset(_, queryset: models.QuerySet | None, info: Info): - return get_queryset_for_model(TimeTrack, queryset) + return get_queryset_for_model(TimeEntry, queryset) @strawberry_django.field - async def user(self, root: TimeTrack, info: Info) -> UserType: + async def user(self, root: TimeEntry, info: Info) -> UserType: return await info.context.dl.user.load_user.load(root.user_id) @strawberry_django.field - async def task(self, root: TimeTrack, info: Info) -> TaskType: + async def task(self, root: TimeEntry, info: Info) -> TaskType: return await info.context.dl.track.load_task.load(root.task_id) diff --git a/schema.graphql b/schema.graphql index 97bf5c1..6623731 100644 --- a/schema.graphql +++ b/schema.graphql @@ -1,5 +1,5 @@ type AppEnumCollection { - TimeTrackTaskType: [AppEnumCollectionTimeTrackTaskType!]! + TimeEntryTaskType: [AppEnumCollectionTimeEntryTaskType!]! JournalLeaveType: [AppEnumCollectionJournalLeaveType!]! } @@ -8,8 +8,8 @@ type AppEnumCollectionJournalLeaveType { label: String! } -type AppEnumCollectionTimeTrackTaskType { - key: TimeTrackTaskTypeEnum! +type AppEnumCollectionTimeEntryTaskType { + key: TimeEntryTaskTypeEnum! label: String! } @@ -35,12 +35,20 @@ input ClientFilter { DISTINCT: Boolean } +interface ClientIdMixin { + clientId: ID! +} + input ClientOrder { id: Ordering name: Ordering } -type ClientType { +type ClientType implements UserResourceTypeMixin { + createdAt: DateTime! + modifiedAt: DateTime! + createdBy: UserType! + modifiedBy: UserType! id: ID! name: String! } @@ -68,7 +76,11 @@ input ContractOrder { createdAt: Ordering } -type ContractType { +type ContractType implements UserResourceTypeMixin { + createdAt: DateTime! + modifiedAt: DateTime! + createdBy: UserType! + modifiedBy: UserType! id: ID! projectId: ID! totalEstimatedHours: Float @@ -101,7 +113,11 @@ input ContractorOrder { name: Ordering } -type ContractorType { +type ContractorType implements UserResourceTypeMixin { + createdAt: DateTime! + modifiedAt: DateTime! + createdBy: UserType! + modifiedBy: UserType! id: ID! name: String! } @@ -160,6 +176,9 @@ input DateRangeLookup { end: Date = null } +"""Date with time (isoformat)""" +scalar DateTime + input DjangoModelFilterInput { pk: ID! } @@ -267,14 +286,14 @@ enum Ordering { } type PrivateMutation { - createTimeTrack(data: TimeTrackCreateInput!): TimeTrackTypeMutationResponseType! - updateTimeTrack(data: TimeTrackUpdateInput!): TimeTrackTypeMutationResponseType! + createTimeEntry(data: TimeEntryCreateInput!): TimeEntryTypeMutationResponseType! + updateTimeEntry(id: ID!, data: TimeEntryUpdateInput!): TimeEntryTypeMutationResponseType! + bulkTimeEntry(items: [TimeEntryBulkCreateInput!] = [], deleteIds: [ID!] = []): TimeEntryTypeBulkMutationResponseType! updateJournal(date: Date!, data: JournalUpdateInput!): JournalTypeMutationResponseType! id: ID! } type PrivateQuery { - noop: ID! clients(filters: ClientFilter, order: ClientOrder, pagination: OffsetPaginationInput): ClientTypeCountList! contractors(filters: ContractorFilter, order: ContractorOrder, pagination: OffsetPaginationInput): ContractorTypeCountList! projects(filters: ProjectFilter, order: ProjectOrder, pagination: OffsetPaginationInput): ProjectTypeCountList! @@ -283,14 +302,14 @@ type PrivateQuery { project(pk: ID!): ProjectType contracts(filters: ContractFilter, order: ContractOrder, pagination: OffsetPaginationInput): ContractTypeCountList! tasks(filters: TaskFilter, order: TaskOrder, pagination: OffsetPaginationInput): TaskTypeCountList! - timeTracks(filters: TimeTrackFilter, order: TimeTrackOrder, pagination: OffsetPaginationInput): TimeTrackTypeCountList! + timeEntries(filters: TimeEntryFilter, order: TimeEntryOrder, pagination: OffsetPaginationInput): TimeEntryTypeCountList! """Return all UnArchived contracts""" - allContracts: [ContractType!]! + allActiveContracts: [ContractType!]! """Return all UnArchived tasks""" - allTasks: [TaskType!]! - myTimeTracks(date: Date!): [TimeTrackType!]! + allActiveTasks: [TaskType!]! + myTimeEntries(date: Date!): [TimeEntryType!]! contract(pk: ID!): ContractType task(pk: ID!): TaskType journal(date: Date!): JournalType @@ -312,7 +331,11 @@ input ProjectOrder { name: Ordering } -type ProjectType { +type ProjectType implements UserResourceTypeMixin { + createdAt: DateTime! + modifiedAt: DateTime! + createdBy: UserType! + modifiedBy: UserType! id: ID! clientId: ID! contractorId: ID! @@ -411,7 +434,11 @@ input TaskOrder { createdAt: Ordering } -type TaskType { +type TaskType implements UserResourceTypeMixin { + createdAt: DateTime! + modifiedAt: DateTime! + createdBy: UserType! + modifiedBy: UserType! id: ID! estimatedHours: Float isArchived: Boolean! @@ -427,38 +454,54 @@ type TaskTypeCountList { items: [TaskType!]! } +"""Time (isoformat)""" +scalar Time + scalar TimeDuration -input TimeTrackCreateInput { +input TimeEntryBulkCreateInput { + task: ID! + date: Date! + taskType: TimeEntryTaskTypeEnum! + id: ID + description: String + isDone: Boolean + duration: TimeDuration + startTime: Time + clientId: ID +} + +input TimeEntryCreateInput { task: ID! date: Date! - taskType: TimeTrackTaskTypeEnum! + taskType: TimeEntryTaskTypeEnum! description: String isDone: Boolean duration: TimeDuration + startTime: Time clientId: ID } -input TimeTrackFilter { +input TimeEntryFilter { id: IDBaseFilterLookup user: DjangoModelFilterInput task: DjangoModelFilterInput date: DateDateFilterLookup - taskTypes: [TimeTrackTaskTypeEnum!]! - AND: TimeTrackFilter - OR: TimeTrackFilter - NOT: TimeTrackFilter + taskTypes: [TimeEntryTaskTypeEnum!]! + AND: TimeEntryFilter + OR: TimeEntryFilter + NOT: TimeEntryFilter DISTINCT: Boolean project: ID contract: ID } -input TimeTrackOrder { +input TimeEntryOrder { id: Ordering date: Ordering } -enum TimeTrackTaskTypeEnum { +enum TimeEntryTaskTypeEnum { DESIGN DEVELOPMENT DEV_OPS @@ -468,40 +511,49 @@ enum TimeTrackTaskTypeEnum { QUALITY_ASSURANCE } -type TimeTrackType { +type TimeEntryType implements ClientIdMixin { + clientId: ID! id: ID! date: Date! userId: ID! taskId: ID! isDone: Boolean! + startTime: Time duration: TimeDuration - taskType: TimeTrackTaskTypeEnum! + taskType: TimeEntryTaskTypeEnum! taskTypeDisplay: String! description: String user: UserType! task: TaskType! } -type TimeTrackTypeCountList { +type TimeEntryTypeBulkMutationResponseType { + errors: [CustomErrorType!] + results: [TimeEntryType!] + deleted: [TimeEntryType!] +} + +type TimeEntryTypeCountList { limit: Int! offset: Int! count: Int! - items: [TimeTrackType!]! + items: [TimeEntryType!]! } -type TimeTrackTypeMutationResponseType { +type TimeEntryTypeMutationResponseType { ok: Boolean! errors: CustomErrorType - result: TimeTrackType + result: TimeEntryType } -input TimeTrackUpdateInput { +input TimeEntryUpdateInput { task: ID date: Date - taskType: TimeTrackTaskTypeEnum + taskType: TimeEntryTaskTypeEnum description: String isDone: Boolean duration: TimeDuration + startTime: Time clientId: ID } @@ -519,6 +571,13 @@ type UserMeTypeMutationResponseType { result: UserMeType } +interface UserResourceTypeMixin { + createdAt: DateTime! + modifiedAt: DateTime! + createdBy: UserType! + modifiedBy: UserType! +} + type UserType { id: ID! firstName: String! From c94ab331b9fb003565f7383cc0c872423d4d6b3c Mon Sep 17 00:00:00 2001 From: thenav56 Date: Mon, 5 Aug 2024 17:39:34 +0545 Subject: [PATCH 05/51] Change TimeEntry.TaskType -> TimeEntry.Type --- apps/track/admin.py | 4 +- apps/track/enums.py | 4 +- apps/track/filters.py | 4 +- .../0006_rename_task_type_timeentry_type.py | 18 +++++++++ apps/track/models.py | 4 +- apps/track/serializers.py | 2 +- apps/track/tests/test_mutations.py | 20 +++++----- apps/track/tests/test_queries.py | 10 ++--- apps/track/types.py | 4 +- schema.graphql | 38 +++++++++---------- 10 files changed, 63 insertions(+), 45 deletions(-) create mode 100644 apps/track/migrations/0006_rename_task_type_timeentry_type.py diff --git a/apps/track/admin.py b/apps/track/admin.py index 914999b..29e58ec 100644 --- a/apps/track/admin.py +++ b/apps/track/admin.py @@ -61,7 +61,7 @@ def get_contract(self, obj): class TimeEntryAdmin(admin.ModelAdmin): list_filter = ( "date", - "task_type", + "type", "is_done", AutocompleteFilterFactory("Project", "task__contract__project"), AutocompleteFilterFactory("Contract", "task__contract"), @@ -77,7 +77,7 @@ class TimeEntryAdmin(admin.ModelAdmin): "get_project", "get_task", "get_user", - "task_type", + "type", "date", "duration", "is_done", diff --git a/apps/track/enums.py b/apps/track/enums.py index 1ed2bf0..7c2fa8e 100644 --- a/apps/track/enums.py +++ b/apps/track/enums.py @@ -4,7 +4,7 @@ from .models import TimeEntry -TimeEntryTaskTypeEnum = strawberry.enum(TimeEntry.TaskType, name="TimeEntryTaskTypeEnum") +TimeEntryTypeEnum = strawberry.enum(TimeEntry.Type, name="TimeEntryTypeEnum") -enum_map = {get_enum_name_from_django_field(field): enum for field, enum in ((TimeEntry.task_type, TimeEntryTaskTypeEnum),)} +enum_map = {get_enum_name_from_django_field(field): enum for field, enum in ((TimeEntry.type, TimeEntryTypeEnum),)} diff --git a/apps/track/filters.py b/apps/track/filters.py index b9223ed..fcbc16d 100644 --- a/apps/track/filters.py +++ b/apps/track/filters.py @@ -2,7 +2,7 @@ import strawberry_django from django.db import models -from .enums import TimeEntryTaskTypeEnum +from .enums import TimeEntryTypeEnum from .models import Contract, Task, TimeEntry @@ -36,7 +36,7 @@ class TimeEntryFilter: task: strawberry.auto date: strawberry.auto - task_types: list[TimeEntryTaskTypeEnum] # type: ignore[reportInvalidTypeForm] + types: list[TimeEntryTypeEnum] # type: ignore[reportInvalidTypeForm] @strawberry_django.filter_field def project( diff --git a/apps/track/migrations/0006_rename_task_type_timeentry_type.py b/apps/track/migrations/0006_rename_task_type_timeentry_type.py new file mode 100644 index 0000000..8212048 --- /dev/null +++ b/apps/track/migrations/0006_rename_task_type_timeentry_type.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.14 on 2024-08-05 11:52 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("track", "0005_rename_timetrack_timeentry"), + ] + + operations = [ + migrations.RenameField( + model_name="timeentry", + old_name="task_type", + new_name="type", + ), + ] diff --git a/apps/track/models.py b/apps/track/models.py index 9924128..df268f6 100644 --- a/apps/track/models.py +++ b/apps/track/models.py @@ -32,7 +32,7 @@ def __str__(self): class TimeEntry(models.Model): - class TaskType(models.IntegerChoices): + class Type(models.IntegerChoices): # Using 4 digit for future ordering support DESIGN = 1000, _("Design") DEVELOPMENT = 1100, _("Development") @@ -46,7 +46,7 @@ class TaskType(models.IntegerChoices): task = models.ForeignKey(Task, on_delete=models.PROTECT, related_name="+") date = models.DateField() - task_type = models.PositiveSmallIntegerField(choices=TaskType.choices) + type = models.PositiveSmallIntegerField(choices=Type.choices) start_time = models.TimeField(null=True, blank=True) description = models.TextField(blank=True) diff --git a/apps/track/serializers.py b/apps/track/serializers.py index 4371be1..35c99a7 100644 --- a/apps/track/serializers.py +++ b/apps/track/serializers.py @@ -12,7 +12,7 @@ class Meta: # type: ignore[reportIncompatibleVariab] fields = ( "task", "date", - "task_type", + "type", "description", "is_done", "duration", diff --git a/apps/track/tests/test_mutations.py b/apps/track/tests/test_mutations.py index 626df07..972ff34 100644 --- a/apps/track/tests/test_mutations.py +++ b/apps/track/tests/test_mutations.py @@ -14,7 +14,7 @@ class Mutation: userId date taskId - taskType + type isDone duration description @@ -77,7 +77,7 @@ def setUpClass(cls): cls.common_time_entry_kwargs = dict( task=cls.active_tasks[1], date="2021-01-02", - task_type=TimeEntry.TaskType.DEVELOPMENT, + type=TimeEntry.Type.DEVELOPMENT, description="Norm description", is_done=False, duration="00:40", @@ -108,7 +108,7 @@ def test_bulk_time_entry_create(self): dict( task=self.gID(self.active_tasks[0].pk), date="2021-01-01", - taskType=self.genum(TimeEntry.TaskType.DEVELOPMENT), + type=self.genum(TimeEntry.Type.DEVELOPMENT), description="Normal description", isDone=True, duration=30 * 60, @@ -145,7 +145,7 @@ def test_bulk_time_entry_update(self): id=self.gID(time_entries[0].pk), task=self.gID(self.active_tasks[0].pk), date="2021-01-01", - taskType=self.genum(TimeEntry.TaskType.DESIGN), + type=self.genum(TimeEntry.Type.DESIGN), description="Normal description - 0", isDone=True, duration=30 * 60, @@ -156,7 +156,7 @@ def test_bulk_time_entry_update(self): id=self.gID(time_entries[1].pk), task=self.gID(self.active_tasks[0].pk), date="2021-01-02", - taskType=self.genum(TimeEntry.TaskType.DEV_OPS), + type=self.genum(TimeEntry.Type.DEV_OPS), description="Normal description - 1", duration=30 * 60, startTime="09:32:00", @@ -165,7 +165,7 @@ def test_bulk_time_entry_update(self): dict( id=self.gID(time_entries[2].pk), task=self.gID(self.active_tasks[0].pk), - taskType=self.genum(TimeEntry.TaskType.DEV_OPS), + type=self.genum(TimeEntry.Type.DEV_OPS), date="2021-01-02", description="Normal description - 2", clientId="client-id-03", @@ -259,7 +259,7 @@ def test_bulk_time_entry_mix(self): dict( task=self.gID(self.active_tasks[0].pk), date="2021-01-01", - taskType=self.genum(TimeEntry.TaskType.DEVELOPMENT), + type=self.genum(TimeEntry.Type.DEVELOPMENT), description="Normal description - 0", isDone=True, duration=30 * 60, @@ -271,7 +271,7 @@ def test_bulk_time_entry_mix(self): id=self.gID(time_entries[0].pk), task=self.gID(self.active_tasks[0].pk), date="2021-01-01", - taskType=self.genum(TimeEntry.TaskType.DESIGN), + type=self.genum(TimeEntry.Type.DESIGN), description="Normal description - 1", isDone=True, duration=30 * 60, @@ -282,7 +282,7 @@ def test_bulk_time_entry_mix(self): id=self.gID(time_entries[1].pk), task=self.gID(self.active_tasks[0].pk), date="2021-01-02", - taskType=self.genum(TimeEntry.TaskType.DEV_OPS), + type=self.genum(TimeEntry.Type.DEV_OPS), description="Normal description - 2", duration=30 * 60, startTime="09:32:00", @@ -292,7 +292,7 @@ def test_bulk_time_entry_mix(self): dict( id=self.gID(time_entries[2].pk), task=self.gID(self.active_tasks[0].pk), - taskType=self.genum(TimeEntry.TaskType.DEV_OPS), + type=self.genum(TimeEntry.Type.DEV_OPS), date="2021-01-02", description="Normal description - 3", clientId="client-id-03", diff --git a/apps/track/tests/test_queries.py b/apps/track/tests/test_queries.py index 5e4d87c..011dbd4 100644 --- a/apps/track/tests/test_queries.py +++ b/apps/track/tests/test_queries.py @@ -55,8 +55,8 @@ class Query: id name } - taskType - taskTypeDisplay + type + typeDisplay userId user { id @@ -194,7 +194,7 @@ def test_my_time_entries(self): for count, task in tasks: common_kwargs = dict( user=self.user, - task_type=TimeEntry.TaskType.DEVELOPMENT, + type=TimeEntry.Type.DEVELOPMENT, duration="00:30", task=task, date=date, @@ -228,8 +228,8 @@ def test_my_time_entries(self): id=self.gID(entry.task.id), name=self.gID(entry.task.name), ), - taskType=self.genum(entry.task_type), - taskTypeDisplay=entry.task_type.label, + type=self.genum(entry.type), + typeDisplay=entry.type.label, userId=self.gID(self.user.id), user=dict( id=self.gID(self.user.id), diff --git a/apps/track/types.py b/apps/track/types.py index 795d390..613ab7c 100644 --- a/apps/track/types.py +++ b/apps/track/types.py @@ -63,8 +63,8 @@ class TimeEntryType(ClientIdMixin): start_time: strawberry.auto duration: TimeDuration | None - task_type = enum_field(TimeEntry.task_type) - task_type_display = enum_display_field(TimeEntry.task_type) + type = enum_field(TimeEntry.type) + type_display = enum_display_field(TimeEntry.type) description = string_field(TimeEntry.description) @staticmethod diff --git a/schema.graphql b/schema.graphql index 6623731..bde40f8 100644 --- a/schema.graphql +++ b/schema.graphql @@ -1,5 +1,5 @@ type AppEnumCollection { - TimeEntryTaskType: [AppEnumCollectionTimeEntryTaskType!]! + TimeEntryType: [AppEnumCollectionTimeEntryType!]! JournalLeaveType: [AppEnumCollectionJournalLeaveType!]! } @@ -8,8 +8,8 @@ type AppEnumCollectionJournalLeaveType { label: String! } -type AppEnumCollectionTimeEntryTaskType { - key: TimeEntryTaskTypeEnum! +type AppEnumCollectionTimeEntryType { + key: TimeEntryTypeEnum! label: String! } @@ -462,7 +462,7 @@ scalar TimeDuration input TimeEntryBulkCreateInput { task: ID! date: Date! - taskType: TimeEntryTaskTypeEnum! + type: TimeEntryTypeEnum! id: ID description: String isDone: Boolean @@ -474,7 +474,7 @@ input TimeEntryBulkCreateInput { input TimeEntryCreateInput { task: ID! date: Date! - taskType: TimeEntryTaskTypeEnum! + type: TimeEntryTypeEnum! description: String isDone: Boolean duration: TimeDuration @@ -487,7 +487,7 @@ input TimeEntryFilter { user: DjangoModelFilterInput task: DjangoModelFilterInput date: DateDateFilterLookup - taskTypes: [TimeEntryTaskTypeEnum!]! + types: [TimeEntryTypeEnum!]! AND: TimeEntryFilter OR: TimeEntryFilter NOT: TimeEntryFilter @@ -501,16 +501,6 @@ input TimeEntryOrder { date: Ordering } -enum TimeEntryTaskTypeEnum { - DESIGN - DEVELOPMENT - DEV_OPS - DOCUMENTATION - INTERNAL_DISCUSSION - MEETING - QUALITY_ASSURANCE -} - type TimeEntryType implements ClientIdMixin { clientId: ID! id: ID! @@ -520,8 +510,8 @@ type TimeEntryType implements ClientIdMixin { isDone: Boolean! startTime: Time duration: TimeDuration - taskType: TimeEntryTaskTypeEnum! - taskTypeDisplay: String! + type: TimeEntryTypeEnum! + typeDisplay: String! description: String user: UserType! task: TaskType! @@ -540,6 +530,16 @@ type TimeEntryTypeCountList { items: [TimeEntryType!]! } +enum TimeEntryTypeEnum { + DESIGN + DEVELOPMENT + DEV_OPS + DOCUMENTATION + INTERNAL_DISCUSSION + MEETING + QUALITY_ASSURANCE +} + type TimeEntryTypeMutationResponseType { ok: Boolean! errors: CustomErrorType @@ -549,7 +549,7 @@ type TimeEntryTypeMutationResponseType { input TimeEntryUpdateInput { task: ID date: Date - taskType: TimeEntryTaskTypeEnum + type: TimeEntryTypeEnum description: String isDone: Boolean duration: TimeDuration From e34fa6c0fc0311b66b51c1dd736432b23b7a206d Mon Sep 17 00:00:00 2001 From: thenav56 Date: Mon, 5 Aug 2024 17:40:41 +0545 Subject: [PATCH 06/51] Remove unused tests.py --- apps/common/tests.py | 2 -- apps/project/tests.py | 2 -- apps/track/tests.py | 2 -- apps/user/tests.py | 2 -- 4 files changed, 8 deletions(-) delete mode 100644 apps/common/tests.py delete mode 100644 apps/project/tests.py delete mode 100644 apps/track/tests.py delete mode 100644 apps/user/tests.py diff --git a/apps/common/tests.py b/apps/common/tests.py deleted file mode 100644 index 601fc86..0000000 --- a/apps/common/tests.py +++ /dev/null @@ -1,2 +0,0 @@ -# from django.test import TestCase -# Create your tests here. diff --git a/apps/project/tests.py b/apps/project/tests.py deleted file mode 100644 index 601fc86..0000000 --- a/apps/project/tests.py +++ /dev/null @@ -1,2 +0,0 @@ -# from django.test import TestCase -# Create your tests here. diff --git a/apps/track/tests.py b/apps/track/tests.py deleted file mode 100644 index 601fc86..0000000 --- a/apps/track/tests.py +++ /dev/null @@ -1,2 +0,0 @@ -# from django.test import TestCase -# Create your tests here. diff --git a/apps/user/tests.py b/apps/user/tests.py deleted file mode 100644 index 601fc86..0000000 --- a/apps/user/tests.py +++ /dev/null @@ -1,2 +0,0 @@ -# from django.test import TestCase -# Create your tests here. From 86e6b034e038476158ffa301379c53953632a464 Mon Sep 17 00:00:00 2001 From: thenav56 Date: Mon, 5 Aug 2024 18:36:03 +0545 Subject: [PATCH 07/51] Add test for google OAuth --- apps/common/tests/__init__.py | 0 apps/common/tests/test_google_oauth.py | 109 +++++++++++++++++++++++++ apps/common/views.py | 22 ++--- apps/user/factories.py | 1 + main/tests.py | 2 + main/urls.py | 2 +- 6 files changed, 122 insertions(+), 14 deletions(-) create mode 100644 apps/common/tests/__init__.py create mode 100644 apps/common/tests/test_google_oauth.py diff --git a/apps/common/tests/__init__.py b/apps/common/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/common/tests/test_google_oauth.py b/apps/common/tests/test_google_oauth.py new file mode 100644 index 0000000..7c56b28 --- /dev/null +++ b/apps/common/tests/test_google_oauth.py @@ -0,0 +1,109 @@ +from unittest.mock import patch + +from django.contrib.sessions.middleware import SessionMiddleware +from django.test import RequestFactory +from django.urls import reverse + +from apps.common.views import google_oauth +from apps.user.factories import UserFactory +from apps.user.models import User +from main.tests import TestCase + + +class TestGoogleOAuth(TestCase): + def setUp(self): + self.factory = RequestFactory() + self.mock_oauth2_valid_response = { + "hd": "togglecorp.com", + "email": "john.cena@togglecorp.com", + "email_verified": True, + "picture": "https://lh3.googleusercontent.com/a/john.cena.png", + "given_name": "John", + "family_name": "Cena", + } + UserFactory.create_batch(3) # Noise data + + @patch("apps.common.views.id_token.verify_oauth2_token") + def test_sign_up(self, verify_oauth2_token_mock): + request = self.factory.post( + reverse("google_oauth"), + data={"credential": "XYZ"}, + # NOTE: Using content_type='application/json' ignores the data param + ) + middleware = SessionMiddleware(self.no_op) # type: ignore[reportArgumentType] + middleware.process_request(request) + request.session.save() + + def _query_count_check(count): + assert User.objects.filter(email=self.mock_oauth2_valid_response["email"]).count() == count + + _query_count_check(0) + assert list(request.session.items()) == [] + + # Failure response 01 + verify_oauth2_token_mock.return_value = { + **self.mock_oauth2_valid_response, + "email_verified": False, + } + response = google_oauth(request) + assert response.status_code == 400 + assert list(request.session.items()) == [] + _query_count_check(0) + + # Failure response 02 + verify_oauth2_token_mock.side_effect = lambda *_: (_ for _ in ()).throw(ValueError("Random error")) + response = google_oauth(request) + assert response.status_code == 403 + assert list(request.session.items()) == [] + _query_count_check(0) + + # Success response + verify_oauth2_token_mock.reset_mock(side_effect=True) + verify_oauth2_token_mock.return_value = {**self.mock_oauth2_valid_response} + response = google_oauth(request) + assert response.status_code == 302 + assert list(request.session.items()) != [] + assert len(list(request.session.items())) == 3 + assert list(request.session.items())[0] == ( + "_auth_user_id", + str(User.objects.get(email=self.mock_oauth2_valid_response["email"]).pk), + ) + _query_count_check(1) + + @patch("apps.common.views.id_token.verify_oauth2_token") + def test_sign_in(self, verify_oauth2_token_mock): + user = UserFactory.create(email=self.mock_oauth2_valid_response["email"]) + UserFactory.create_batch(3) # Noise data + + request = self.factory.post( + reverse("google_oauth"), + data={"credential": "XYZ"}, + # NOTE: Using content_type='application/json' ignores the data param + ) + middleware = SessionMiddleware(self.no_op) # type: ignore[reportArgumentType] + middleware.process_request(request) + request.session.save() + + # Failure response 01 + verify_oauth2_token_mock.return_value = { + **self.mock_oauth2_valid_response, + "email_verified": False, + } + response = google_oauth(request) + assert list(request.session.items()) == [] + assert response.status_code == 400 + + # Failure response 02 + verify_oauth2_token_mock.side_effect = lambda *_: (_ for _ in ()).throw(ValueError("Random error")) + response = google_oauth(request) + assert list(request.session.items()) == [] + assert response.status_code == 403 + + # Success response + verify_oauth2_token_mock.reset_mock(side_effect=True) + verify_oauth2_token_mock.return_value = {**self.mock_oauth2_valid_response} + response = google_oauth(request) + assert list(request.session.items()) != [] + assert len(list(request.session.items())) == 3 + assert list(request.session.items())[0] == ("_auth_user_id", str(user.pk)) + assert response.status_code == 302 diff --git a/apps/common/views.py b/apps/common/views.py index 55c3fa5..e58712c 100644 --- a/apps/common/views.py +++ b/apps/common/views.py @@ -33,20 +33,16 @@ def google_oauth(request): try: user_data = id_token.verify_oauth2_token(token, requests.Request(), settings.GOOGLE_OAUTH_CLIENT_ID) - """ - { - 'hd': 'togglecorp.com', - 'email': 'xxxxxxxxx@togglecorp.com', - 'email_verified': True, - 'picture': 'https://lh3.googleusercontent.com/a/xx', - 'given_name': 'XXXXX', - 'family_name': 'YYYY', - } - """ - # TODO: Handle this properly - assert user_data["email_verified"] is True + if user_data["email_verified"] is not True: + return HttpResponse( + "Email is not verified", + status=400, + ) except ValueError: - return HttpResponse(status=403) + return HttpResponse( + "Failed to process", + status=403, + ) email = user_data["email"].lower() if user := User.objects.filter(email=email).first(): diff --git a/apps/user/factories.py b/apps/user/factories.py index abb311d..b6fafeb 100644 --- a/apps/user/factories.py +++ b/apps/user/factories.py @@ -12,6 +12,7 @@ class UserFactory(DjangoModelFactory): class Meta: # type: ignore[reportIncompatibleVariab] model = User + skip_postgeneration_save = True @factory.post_generation def password(obj, create, password, **_): diff --git a/main/tests.py b/main/tests.py index 79914e7..6400004 100644 --- a/main/tests.py +++ b/main/tests.py @@ -183,3 +183,5 @@ def assertListDictEqual( [self._dict_with_keys(item, ignore_keys=ignore_keys, include_keys=include_keys) for item in right], messages, ) + + def no_op(*args, **_): ... diff --git a/main/urls.py b/main/urls.py index 4280786..160794a 100644 --- a/main/urls.py +++ b/main/urls.py @@ -17,7 +17,7 @@ graphql_ide=False, ), ), - path("o/google", google_oauth), + path("o/google", google_oauth, name="google_oauth"), ] From 0fea0036712871f257e81a134a33c67b4b5d9ddb Mon Sep 17 00:00:00 2001 From: thenav56 Date: Tue, 6 Aug 2024 16:03:02 +0545 Subject: [PATCH 08/51] Fix session name --- main/settings.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/main/settings.py b/main/settings.py index 0e1b2ca..73da487 100644 --- a/main/settings.py +++ b/main/settings.py @@ -104,7 +104,7 @@ ALLOWED_HOSTS = env.list("DJANGO_ALLOWED_HOST") -APP_SITE_NAME = "Questionnaire Builder" +APP_SITE_NAME = "Timur" APP_HTTP_PROTOCOL = env("APP_HTTP_PROTOCOL") APP_DOMAIN = env("APP_DOMAIN") APP_FRONTEND_HOST = env("APP_FRONTEND_HOST") @@ -344,8 +344,8 @@ # Security Header configuration -SESSION_COOKIE_NAME = f"questionnaire-builder-{APP_ENVIRONMENT}-sessionid" -CSRF_COOKIE_NAME = f"questionnaire-builder-{APP_ENVIRONMENT}-csrftoken" +SESSION_COOKIE_NAME = f"timur-{APP_ENVIRONMENT}-sessionid" +CSRF_COOKIE_NAME = f"timur-{APP_ENVIRONMENT}-csrftoken" SECURE_BROWSER_XSS_FILTER = True SECURE_CONTENT_TYPE_NOSNIFF = True X_FRAME_OPTIONS = "DENY" From 962104e1e21ed669216f356858ecc65de8acdb9f Mon Sep 17 00:00:00 2001 From: thenav56 Date: Tue, 6 Aug 2024 18:48:07 +0545 Subject: [PATCH 09/51] Add Status (TODO|DOING|DONE) - Add display_picture - Add redirect_to for dev/sign_in --- .github/workflows/ci.yml | 9 +++-- apps/common/views.py | 9 +++-- apps/track/admin.py | 4 +-- apps/track/enums.py | 9 ++++- apps/track/factories.py | 2 ++ ...move_timeentry_is_done_timeentry_status.py | 23 +++++++++++++ apps/track/models.py | 7 +++- apps/track/serializers.py | 2 +- apps/track/tests/test_mutations.py | 20 ++++++----- apps/track/tests/test_queries.py | 4 +-- apps/track/types.py | 4 ++- ...r_display_picture_alter_user_department.py | 34 +++++++++++++++++++ apps/user/models.py | 5 +-- apps/user/queries.py | 2 ++ schema.graphql | 22 +++++++++--- 15 files changed, 127 insertions(+), 29 deletions(-) create mode 100644 apps/track/migrations/0007_remove_timeentry_is_done_timeentry_status.py create mode 100644 apps/user/migrations/0004_user_display_picture_alter_user_department.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 418600e..687be9e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -67,11 +67,10 @@ jobs: exit 1; } - # TODO: Run this for CI - # - name: 🤞 Run Test 🧪 & Publish coverage to code climate - # env: - # DOCKER_IMAGE_BACKEND: ${{ steps.prep.outputs.tagged_image }} - # run: docker-compose -f gh-docker-compose.yml run --rm web /code/scripts/run_tests.sh + - name: 🤞 Run Test 🧪 & Publish coverage to code climate + env: + DOCKER_IMAGE_BACKEND: ${{ steps.prep.outputs.tagged_image }} + run: docker-compose -f gh-docker-compose.yml run --rm web /code/scripts/run_tests.sh # Temp fix # https://github.com/docker/build-push-action/blob/master/docs/advanced/cache.md#github-cache diff --git a/apps/common/views.py b/apps/common/views.py index e58712c..78e9bdf 100644 --- a/apps/common/views.py +++ b/apps/common/views.py @@ -30,6 +30,7 @@ def google_oauth(request): Google calls this URL after the user has signed in with their Google account. """ token = request.POST["credential"] + redirect_to = request.GET.get("redirect_to") try: user_data = id_token.verify_oauth2_token(token, requests.Request(), settings.GOOGLE_OAUTH_CLIENT_ID) @@ -48,15 +49,19 @@ def google_oauth(request): if user := User.objects.filter(email=email).first(): user.first_name = user_data["given_name"] user.last_name = user_data["family_name"] - # TODO: User picture? - user.save(update_fields=("first_name", "last_name", "display_name")) + user.display_picture = user_data["picture"] + user.save(update_fields=("first_name", "last_name", "display_name", "display_picture")) login(request, user) else: new_user = User.objects.create( email=email, first_name=user_data["given_name"], last_name=user_data["family_name"], + display_picture=user_data["picture"], ) login(request, new_user) + if redirect_to: + return redirect(redirect_to) + return redirect(settings.APP_FRONTEND_HOST) diff --git a/apps/track/admin.py b/apps/track/admin.py index 29e58ec..3f7a45b 100644 --- a/apps/track/admin.py +++ b/apps/track/admin.py @@ -62,7 +62,7 @@ class TimeEntryAdmin(admin.ModelAdmin): list_filter = ( "date", "type", - "is_done", + "status", AutocompleteFilterFactory("Project", "task__contract__project"), AutocompleteFilterFactory("Contract", "task__contract"), AutocompleteFilterFactory("Task", "task"), @@ -80,7 +80,7 @@ class TimeEntryAdmin(admin.ModelAdmin): "type", "date", "duration", - "is_done", + "status", ) def get_queryset(self, request: HttpRequest) -> models.QuerySet[Contract]: diff --git a/apps/track/enums.py b/apps/track/enums.py index 7c2fa8e..f1bc9fa 100644 --- a/apps/track/enums.py +++ b/apps/track/enums.py @@ -5,6 +5,13 @@ from .models import TimeEntry TimeEntryTypeEnum = strawberry.enum(TimeEntry.Type, name="TimeEntryTypeEnum") +TimeEntryStatusEnum = strawberry.enum(TimeEntry.Status, name="TimeEntryStatusEnum") -enum_map = {get_enum_name_from_django_field(field): enum for field, enum in ((TimeEntry.type, TimeEntryTypeEnum),)} +enum_map = { + get_enum_name_from_django_field(field): enum + for field, enum in ( + (TimeEntry.type, TimeEntryTypeEnum), + (TimeEntry.status, TimeEntryStatusEnum), + ) +} diff --git a/apps/track/factories.py b/apps/track/factories.py index af6fb0a..5b12d05 100644 --- a/apps/track/factories.py +++ b/apps/track/factories.py @@ -19,5 +19,7 @@ class Meta: # type: ignore[reportIncompatibleVariab] class TimeEntryFactory(DjangoModelFactory): + status = TimeEntry.Status.TODO + class Meta: # type: ignore[reportIncompatibleVariab] model = TimeEntry diff --git a/apps/track/migrations/0007_remove_timeentry_is_done_timeentry_status.py b/apps/track/migrations/0007_remove_timeentry_is_done_timeentry_status.py new file mode 100644 index 0000000..eda58fa --- /dev/null +++ b/apps/track/migrations/0007_remove_timeentry_is_done_timeentry_status.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2.14 on 2024-08-06 12:50 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("track", "0006_rename_task_type_timeentry_type"), + ] + + operations = [ + migrations.RemoveField( + model_name="timeentry", + name="is_done", + ), + migrations.AddField( + model_name="timeentry", + name="status", + field=models.PositiveSmallIntegerField(choices=[(1, "Doing"), (2, "Done"), (3, "TODO")], default=2), + preserve_default=False, + ), + ] diff --git a/apps/track/models.py b/apps/track/models.py index df268f6..022929e 100644 --- a/apps/track/models.py +++ b/apps/track/models.py @@ -42,15 +42,20 @@ class Type(models.IntegerChoices): MEETING = 4000, _("Meeting") QUALITY_ASSURANCE = 5000, _("QA") + class Status(models.IntegerChoices): + DOING = 1, _("Doing") + DONE = 2, _("Done") + TODO = 3, _("TODO") + user = models.ForeignKey(User, on_delete=models.PROTECT, related_name="+") task = models.ForeignKey(Task, on_delete=models.PROTECT, related_name="+") date = models.DateField() type = models.PositiveSmallIntegerField(choices=Type.choices) + status = models.PositiveSmallIntegerField(choices=Status.choices) start_time = models.TimeField(null=True, blank=True) description = models.TextField(blank=True) - is_done = models.BooleanField(default=False) duration = models.DurationField(null=True, blank=True) diff --git a/apps/track/serializers.py b/apps/track/serializers.py index 35c99a7..52a5973 100644 --- a/apps/track/serializers.py +++ b/apps/track/serializers.py @@ -14,7 +14,7 @@ class Meta: # type: ignore[reportIncompatibleVariab] "date", "type", "description", - "is_done", + "status", "duration", "start_time", "client_id", diff --git a/apps/track/tests/test_mutations.py b/apps/track/tests/test_mutations.py index 972ff34..d3189e1 100644 --- a/apps/track/tests/test_mutations.py +++ b/apps/track/tests/test_mutations.py @@ -15,7 +15,7 @@ class Mutation: date taskId type - isDone + status duration description startTime @@ -79,7 +79,7 @@ def setUpClass(cls): date="2021-01-02", type=TimeEntry.Type.DEVELOPMENT, description="Norm description", - is_done=False, + status=TimeEntry.Status.DOING, duration="00:40", start_time="09:30:00", ) @@ -110,7 +110,7 @@ def test_bulk_time_entry_create(self): date="2021-01-01", type=self.genum(TimeEntry.Type.DEVELOPMENT), description="Normal description", - isDone=True, + status=self.genum(TimeEntry.Status.DOING), duration=30 * 60, startTime="09:30:00", clientId="client-id-01", @@ -147,7 +147,7 @@ def test_bulk_time_entry_update(self): date="2021-01-01", type=self.genum(TimeEntry.Type.DESIGN), description="Normal description - 0", - isDone=True, + status=self.genum(TimeEntry.Status.DOING), duration=30 * 60, startTime="09:31:00", clientId="client-id-01", @@ -158,6 +158,7 @@ def test_bulk_time_entry_update(self): date="2021-01-02", type=self.genum(TimeEntry.Type.DEV_OPS), description="Normal description - 1", + status=self.genum(TimeEntry.Status.DONE), duration=30 * 60, startTime="09:32:00", clientId="client-id-02", @@ -166,6 +167,7 @@ def test_bulk_time_entry_update(self): id=self.gID(time_entries[2].pk), task=self.gID(self.active_tasks[0].pk), type=self.genum(TimeEntry.Type.DEV_OPS), + status=self.genum(TimeEntry.Status.DONE), date="2021-01-02", description="Normal description - 2", clientId="client-id-03", @@ -175,7 +177,7 @@ def test_bulk_time_entry_update(self): default_time_entry_kwargs = { "date": self.common_time_entry_kwargs["date"], - "isDone": self.common_time_entry_kwargs["is_done"], + "status": self.common_time_entry_kwargs["status"], "startTime": self.common_time_entry_kwargs["start_time"], "duration": 40 * 60, # self.common_time_entry_kwargs["duration"] "userId": self.gID(self.user.pk), @@ -261,7 +263,7 @@ def test_bulk_time_entry_mix(self): date="2021-01-01", type=self.genum(TimeEntry.Type.DEVELOPMENT), description="Normal description - 0", - isDone=True, + status=self.genum(TimeEntry.Status.DOING), duration=30 * 60, startTime="09:30:00", clientId="client-id-00", @@ -273,7 +275,7 @@ def test_bulk_time_entry_mix(self): date="2021-01-01", type=self.genum(TimeEntry.Type.DESIGN), description="Normal description - 1", - isDone=True, + status=self.genum(TimeEntry.Status.DOING), duration=30 * 60, startTime="09:31:00", clientId="client-id-01", @@ -284,6 +286,7 @@ def test_bulk_time_entry_mix(self): date="2021-01-02", type=self.genum(TimeEntry.Type.DEV_OPS), description="Normal description - 2", + status=self.genum(TimeEntry.Status.DOING), duration=30 * 60, startTime="09:32:00", clientId="client-id-02", @@ -295,6 +298,7 @@ def test_bulk_time_entry_mix(self): type=self.genum(TimeEntry.Type.DEV_OPS), date="2021-01-02", description="Normal description - 3", + status=self.genum(TimeEntry.Status.DOING), clientId="client-id-03", ), ], @@ -303,7 +307,7 @@ def test_bulk_time_entry_mix(self): # From test_bulk_time_entry_update default_time_entry_kwargs = { "date": self.common_time_entry_kwargs["date"], - "isDone": self.common_time_entry_kwargs["is_done"], + "status": self.common_time_entry_kwargs["status"], "startTime": self.common_time_entry_kwargs["start_time"], "duration": 40 * 60, # self.common_time_entry_kwargs["duration"] "userId": self.gID(self.user.pk), diff --git a/apps/track/tests/test_queries.py b/apps/track/tests/test_queries.py index 011dbd4..7b2b3bc 100644 --- a/apps/track/tests/test_queries.py +++ b/apps/track/tests/test_queries.py @@ -62,7 +62,7 @@ class Query: id displayName } - isDone + status duration description startTime @@ -235,7 +235,7 @@ def test_my_time_entries(self): id=self.gID(self.user.id), displayName=self.gID(self.user.display_name), ), - isDone=False, + status=self.genum(entry.status), duration=30 * 60, description=None, startTime=None, diff --git a/apps/track/types.py b/apps/track/types.py index 613ab7c..caf4084 100644 --- a/apps/track/types.py +++ b/apps/track/types.py @@ -59,10 +59,12 @@ class TimeEntryType(ClientIdMixin): date: strawberry.auto user_id: strawberry.ID task_id: strawberry.ID - is_done: strawberry.auto start_time: strawberry.auto duration: TimeDuration | None + status = enum_field(TimeEntry.status) + status_display = enum_display_field(TimeEntry.status) + type = enum_field(TimeEntry.type) type_display = enum_display_field(TimeEntry.type) description = string_field(TimeEntry.description) diff --git a/apps/user/migrations/0004_user_display_picture_alter_user_department.py b/apps/user/migrations/0004_user_display_picture_alter_user_department.py new file mode 100644 index 0000000..d9bec47 --- /dev/null +++ b/apps/user/migrations/0004_user_display_picture_alter_user_department.py @@ -0,0 +1,34 @@ +# Generated by Django 4.2.14 on 2024-08-06 12:50 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("user", "0003_alter_user_department"), + ] + + operations = [ + migrations.AddField( + model_name="user", + name="display_picture", + field=models.URLField(blank=True, null=True), + ), + migrations.AlterField( + model_name="user", + name="department", + field=models.PositiveSmallIntegerField( + blank=True, + choices=[ + (1000, "Data Analyst"), + (1100, "Design"), + (1200, "Development"), + (2000, "Management"), + (3000, "Project Manager"), + (5000, "QA"), + ], + null=True, + ), + ), + ] diff --git a/apps/user/models.py b/apps/user/models.py index 8c9087d..f9d08a1 100644 --- a/apps/user/models.py +++ b/apps/user/models.py @@ -10,7 +10,7 @@ class User(AbstractUser): class Department(models.IntegerChoices): # Using 4 digit for future ordering support DATA_ANALYST = 1000, _("Data Analyst") - DESIGN = 1100, _("Development") + DESIGN = 1100, _("Design") DEVELOPMENT = 1200, _("Development") MANAGEMENT = 2000, _("Management") PROJECT_MANAGER = 3000, _("Project Manager") @@ -27,7 +27,8 @@ class Department(models.IntegerChoices): blank=True, max_length=255, ) - department = models.PositiveSmallIntegerField(choices=Department.choices, null=True) + display_picture = models.URLField(null=True, blank=True) + department = models.PositiveSmallIntegerField(choices=Department.choices, null=True, blank=True) objects: CustomUserManager = CustomUserManager() # type: ignore[reportAssignmentType] diff --git a/apps/user/queries.py b/apps/user/queries.py index 26d7072..9a4b7da 100644 --- a/apps/user/queries.py +++ b/apps/user/queries.py @@ -13,6 +13,7 @@ class UserType: first_name: strawberry.auto last_name: strawberry.auto display_name: strawberry.auto + display_picture: strawberry.auto @strawberry_django.type(User) @@ -22,6 +23,7 @@ class UserMeType(UserType): first_name: strawberry.auto last_name: strawberry.auto display_name: strawberry.auto + display_picture: strawberry.auto @strawberry.type diff --git a/schema.graphql b/schema.graphql index bde40f8..fab24a9 100644 --- a/schema.graphql +++ b/schema.graphql @@ -1,5 +1,6 @@ type AppEnumCollection { TimeEntryType: [AppEnumCollectionTimeEntryType!]! + TimeEntryStatus: [AppEnumCollectionTimeEntryStatus!]! JournalLeaveType: [AppEnumCollectionJournalLeaveType!]! } @@ -8,6 +9,11 @@ type AppEnumCollectionJournalLeaveType { label: String! } +type AppEnumCollectionTimeEntryStatus { + key: TimeEntryStatusEnum! + label: String! +} + type AppEnumCollectionTimeEntryType { key: TimeEntryTypeEnum! label: String! @@ -463,9 +469,9 @@ input TimeEntryBulkCreateInput { task: ID! date: Date! type: TimeEntryTypeEnum! + status: TimeEntryStatusEnum! id: ID description: String - isDone: Boolean duration: TimeDuration startTime: Time clientId: ID @@ -475,8 +481,8 @@ input TimeEntryCreateInput { task: ID! date: Date! type: TimeEntryTypeEnum! + status: TimeEntryStatusEnum! description: String - isDone: Boolean duration: TimeDuration startTime: Time clientId: ID @@ -501,15 +507,22 @@ input TimeEntryOrder { date: Ordering } +enum TimeEntryStatusEnum { + DOING + DONE + TODO +} + type TimeEntryType implements ClientIdMixin { clientId: ID! id: ID! date: Date! userId: ID! taskId: ID! - isDone: Boolean! startTime: Time duration: TimeDuration + status: TimeEntryStatusEnum! + statusDisplay: String! type: TimeEntryTypeEnum! typeDisplay: String! description: String @@ -551,7 +564,7 @@ input TimeEntryUpdateInput { date: Date type: TimeEntryTypeEnum description: String - isDone: Boolean + status: TimeEntryStatusEnum duration: TimeDuration startTime: Time clientId: ID @@ -562,6 +575,7 @@ type UserMeType { firstName: String! lastName: String! displayName: String! + displayPicture: String email: String! } From 306ee69746cc8f4cf2e7ee67ae07ac0bd0cc192e Mon Sep 17 00:00:00 2001 From: thenav56 Date: Tue, 6 Aug 2024 18:55:41 +0545 Subject: [PATCH 10/51] User not authenticated in test fix --- .github/workflows/ci.yml | 6 +++--- apps/user/factories.py | 1 + 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 687be9e..19b35cb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -51,7 +51,7 @@ jobs: env: DOCKER_IMAGE_BACKEND: ${{ steps.prep.outputs.tagged_image }} run: | - docker-compose -f ./gh-docker-compose.yml run --rm web bash -c 'wait-for-it db:5432 && ./manage.py graphql_schema --out /ci-share/schema-latest.graphql' && + docker compose -f ./gh-docker-compose.yml run --rm web bash -c 'wait-for-it db:5432 && ./manage.py graphql_schema --out /ci-share/schema-latest.graphql' && cmp --silent schema.graphql ./ci-share/schema-latest.graphql || { echo 'The schema.graphql is not up to date with the latest changes. Please update and push latest'; diff schema.graphql ./ci-share/schema-latest.graphql; @@ -62,7 +62,7 @@ jobs: env: DOCKER_IMAGE_BACKEND: ${{ steps.prep.outputs.tagged_image }} run: | - docker-compose -f ./gh-docker-compose.yml run --rm web bash -c 'wait-for-it db:5432 && ./manage.py makemigrations --check --dry-run' || { + docker compose -f ./gh-docker-compose.yml run --rm web bash -c 'wait-for-it db:5432 && ./manage.py makemigrations --check --dry-run' || { echo 'There are some changes to be reflected in the migration. Make sure to run makemigrations'; exit 1; } @@ -70,7 +70,7 @@ jobs: - name: 🤞 Run Test 🧪 & Publish coverage to code climate env: DOCKER_IMAGE_BACKEND: ${{ steps.prep.outputs.tagged_image }} - run: docker-compose -f gh-docker-compose.yml run --rm web /code/scripts/run_tests.sh + run: docker compose -f gh-docker-compose.yml run --rm web /code/scripts/run_tests.sh # Temp fix # https://github.com/docker/build-push-action/blob/master/docs/advanced/cache.md#github-cache diff --git a/apps/user/factories.py b/apps/user/factories.py index b6fafeb..3765e5c 100644 --- a/apps/user/factories.py +++ b/apps/user/factories.py @@ -21,3 +21,4 @@ def password(obj, create, password, **_): password_text = password or fuzzy.FuzzyText(length=15).fuzz() obj.set_password(password_text) # type: ignore[reportAttributeAccessIssue] obj.password_text = password_text + obj.save() # type: ignore[reportAttributeAccessIssue] From 366399527d2828b72f152b908f5125ba97032418 Mon Sep 17 00:00:00 2001 From: thenav56 Date: Tue, 6 Aug 2024 19:00:45 +0545 Subject: [PATCH 11/51] Add fake test for showing migrations --- main/tests.py | 10 ++++++++++ scripts/run_tests.sh | 2 +- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/main/tests.py b/main/tests.py index 6400004..710dd72 100644 --- a/main/tests.py +++ b/main/tests.py @@ -185,3 +185,13 @@ def assertListDictEqual( ) def no_op(*args, **_): ... + + +class FakeTest(TestCase): + """ + This test is for running migrations only + docker compose exec web ./manage.py test --keepdb -v 2 main.tests.FakeTest + """ + + def test_fake(self): + pass diff --git a/scripts/run_tests.sh b/scripts/run_tests.sh index 3def457..314f5b2 100755 --- a/scripts/run_tests.sh +++ b/scripts/run_tests.sh @@ -12,7 +12,7 @@ if [ "$CI" == "true" ]; then set -e # To show migration logs - ./manage.py test --keepdb -v 2 main.tests.test_fake + ./manage.py test --keepdb -v 2 main.tests.FakeTest # Run all tests now echo 'import coverage; coverage.process_startup()' > /code/sitecustomize.py From eadc7b46a2d72fa1708de2ab4e491b8c8800096d Mon Sep 17 00:00:00 2001 From: thenav56 Date: Tue, 6 Aug 2024 19:24:46 +0545 Subject: [PATCH 12/51] Add TODO move and DOING clone command - Fix test runner --- apps/track/management/__init__.py | 0 apps/track/management/commands/__init__.py | 0 .../commands/process_not_done_time_entries.py | 44 +++++++++++++++++++ apps/user/admin.py | 1 + gh-docker-compose.yml | 1 + 5 files changed, 46 insertions(+) create mode 100644 apps/track/management/__init__.py create mode 100644 apps/track/management/commands/__init__.py create mode 100644 apps/track/management/commands/process_not_done_time_entries.py diff --git a/apps/track/management/__init__.py b/apps/track/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/track/management/commands/__init__.py b/apps/track/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/track/management/commands/process_not_done_time_entries.py b/apps/track/management/commands/process_not_done_time_entries.py new file mode 100644 index 0000000..9bf22c8 --- /dev/null +++ b/apps/track/management/commands/process_not_done_time_entries.py @@ -0,0 +1,44 @@ +import datetime + +from django.core.management import BaseCommand +from django.utils import timezone + +from apps.track.models import TimeEntry + + +# TODO: Add test cases +class Command(BaseCommand): + help = "Move past TODO and clone past DOING to today" + + def move_todo_entries(self, today: datetime.date): + qs = TimeEntry.objects.filter(status=TimeEntry.Status.TODO, date__lt=today) + resp = qs.update(date=today) + self.stdout.write(self.style.SUCCESS(f"{resp} TODO moved")) + + def clone_doing_entries(self, today: datetime.date): + # Only look for yesterday + qs = TimeEntry.objects.filter( + status=TimeEntry.Status.DOING, + date=today - datetime.timedelta(days=1), + ) + cloned_count = 0 + for time_entry in qs: + existing_qs = TimeEntry.objects.filter( + status=TimeEntry.Status.TODO, + date=today, + # Fields to check similarity + type=time_entry.type, + description=time_entry.description, + ) + if existing_qs.exists(): + continue + time_entry.pk = None # Create a new copy + time_entry.status = TimeEntry.Status.TODO # Use todo Status + time_entry.date = today + time_entry.save() + self.stdout.write(self.style.SUCCESS(f"{cloned_count} DOING cloned")) + + def handle(self, **_): + today = timezone.now().date() + self.move_todo_entries(today) + self.clone_doing_entries(today) diff --git a/apps/user/admin.py b/apps/user/admin.py index d08019c..e6cd2ff 100644 --- a/apps/user/admin.py +++ b/apps/user/admin.py @@ -29,6 +29,7 @@ class UserAdmin(DjangoUserAdmin): "first_name", "last_name", "department", + "display_picture", ) }, ), diff --git a/gh-docker-compose.yml b/gh-docker-compose.yml index ceb83e8..b0cc5f5 100644 --- a/gh-docker-compose.yml +++ b/gh-docker-compose.yml @@ -43,6 +43,7 @@ services: # # Redis config CELERY_REDIS_URL: ${CELERY_REDIS_URL:-redis://redis:6379/0} DJANGO_CACHE_REDIS_URL: ${DJANGO_CACHE_REDIS_URL:-redis://redis:6379/1} + TEST_DJANGO_CACHE_REDIS_URL: ${DJANGO_CACHE_REDIS_URL:-redis://redis:6379/11} # Email config # EMAIL_HOST: ${EMAIL_HOST:-mailpit} # EMAIL_PORT: ${EMAIL_PORT:-1025} From 061abc9b67045784975a3e94db2f0a303a2ac69b Mon Sep 17 00:00:00 2001 From: thenav56 Date: Wed, 7 Aug 2024 13:03:55 +0545 Subject: [PATCH 13/51] Integrate codeclimate --- .codeclimate.yml | 57 ++++++++++++++++++++++++++++++++++++++++ .coveragerc | 25 ++++++++++++++++++ .github/workflows/ci.yml | 1 + 3 files changed, 83 insertions(+) create mode 100644 .codeclimate.yml create mode 100644 .coveragerc diff --git a/.codeclimate.yml b/.codeclimate.yml new file mode 100644 index 0000000..e5af3b1 --- /dev/null +++ b/.codeclimate.yml @@ -0,0 +1,57 @@ +version: "2" +checks: + complex-logic: + enabled: true + config: + threshold: 10 + file-lines: + enabled: true + config: + threshold: 999 + method-complexity: + enabled: true + config: + threshold: 12 + method-count: + enabled: true + config: + threshold: 20 + method-lines: + enabled: true + config: + threshold: 100 + nested-control-flow: + enabled: true + config: + threshold: 4 + return-statements: + enabled: true + config: + threshold: 5 + argument-count: + enabled: false + similar-code: + enabled: false + identical-code: + enabled: false + +plugins: + pep8: + enabled: true + checks: + complexity: + enabled: false +plugins: + fixme: + enabled: true + config: + strings: + - FIXME + - XXX + +exclude_patterns: +- "**/migrations/*" +- "**/snapshots/*" +- "**/tests/*" +- "**/wsgi.py" +- "manage.py" diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..7f0a3eb --- /dev/null +++ b/.coveragerc @@ -0,0 +1,25 @@ +[run] +branch = true +source = . +parallel = true +concurrency = multiprocessing +omit = */migrations/* + */management/* + */snaphosts/* + sitecustomize.py + __init.py__ + */wsgi.py + manage.py + .tox/* + **/apps.py + **/urls.py + **/tests.py + **/test_*.py + export/* + +[report] +show_missing = true +exclude_lines = + # Don't complain if non-runnable code isn't run: + if 0: + if __name__ == .__main__.: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 19b35cb..3871f2f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -69,6 +69,7 @@ jobs: - name: 🤞 Run Test 🧪 & Publish coverage to code climate env: + CC_TEST_REPORTER_ID: ${{ secrets.CODE_CLIMATE_ID }} DOCKER_IMAGE_BACKEND: ${{ steps.prep.outputs.tagged_image }} run: docker compose -f gh-docker-compose.yml run --rm web /code/scripts/run_tests.sh From 9c1cb38f4c9970603a99c347389287f134f9176f Mon Sep 17 00:00:00 2001 From: thenav56 Date: Wed, 7 Aug 2024 13:04:17 +0545 Subject: [PATCH 14/51] Rename project.client -> project.project_client To avoid temporary `client_id` attribute in GraphQL mutation --- .../management/commands/init_development.py | 2 +- apps/project/admin.py | 2 +- apps/project/filters.py | 2 +- ...003_rename_client_project_project_client.py | 18 ++++++++++++++++++ apps/project/models.py | 6 ++++-- apps/project/types.py | 6 +++--- .../migrations/0008_alter_timeentry_options.py | 17 +++++++++++++++++ apps/track/models.py | 4 ++++ apps/track/tests/test_mutations.py | 2 +- apps/track/tests/test_queries.py | 2 +- schema.graphql | 6 +++--- 11 files changed, 54 insertions(+), 13 deletions(-) create mode 100644 apps/project/migrations/0003_rename_client_project_project_client.py create mode 100644 apps/track/migrations/0008_alter_timeentry_options.py diff --git a/apps/common/management/commands/init_development.py b/apps/common/management/commands/init_development.py index 23e360d..1883698 100644 --- a/apps/common/management/commands/init_development.py +++ b/apps/common/management/commands/init_development.py @@ -148,7 +148,7 @@ def get_or_create_contractor(self, creator: User, name: str) -> Contractor: def get_or_create_project(self, creator: User, name: str, client: Client, contractor: Contractor) -> Project: project, created = Project.objects.get_or_create( name=name, - client=client, + project_client=client, contractor=contractor, defaults=self.get_user_resource_kwargs(creator), ) diff --git a/apps/project/admin.py b/apps/project/admin.py index b2301ff..e7d8fcc 100644 --- a/apps/project/admin.py +++ b/apps/project/admin.py @@ -31,7 +31,7 @@ def get_queryset(self, request: HttpRequest) -> models.QuerySet[Client]: class ProjectAdmin(VersionAdmin, UserResourceAdmin): search_fields = ("name",) list_filter = ( - AutocompleteFilterFactory("Client", "client"), + AutocompleteFilterFactory("Client", "project_client"), AutocompleteFilterFactory("Contractor", "contractor"), ) diff --git a/apps/project/filters.py b/apps/project/filters.py index b5e64a8..fdf55a3 100644 --- a/apps/project/filters.py +++ b/apps/project/filters.py @@ -19,5 +19,5 @@ class ContractorFilter: @strawberry_django.filters.filter(Project, lookups=True) class ProjectFilter: id: strawberry.auto - client: strawberry.auto + project_client: strawberry.auto contractor: strawberry.auto diff --git a/apps/project/migrations/0003_rename_client_project_project_client.py b/apps/project/migrations/0003_rename_client_project_project_client.py new file mode 100644 index 0000000..e29e30c --- /dev/null +++ b/apps/project/migrations/0003_rename_client_project_project_client.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.14 on 2024-08-07 05:12 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("project", "0002_initial"), + ] + + operations = [ + migrations.RenameField( + model_name="project", + old_name="client", + new_name="project_client", + ), + ] diff --git a/apps/project/models.py b/apps/project/models.py index f06dbec..876c8f8 100644 --- a/apps/project/models.py +++ b/apps/project/models.py @@ -21,10 +21,12 @@ class Project(UserResource): name = models.CharField(max_length=225) description = models.TextField(blank=True) - client = models.ForeignKey(Client, on_delete=models.PROTECT, related_name="projects") + # NOTE: We use `client_id` for storing client context information temporary. + # This may collide in future. So, using `project_client` instead of just `client` + project_client = models.ForeignKey(Client, on_delete=models.PROTECT, related_name="projects") contractor = models.ForeignKey(Contractor, on_delete=models.PROTECT, related_name="projects") - client_id: int + project_client_id: int contractor_id: int def __str__(self): diff --git a/apps/project/types.py b/apps/project/types.py index bce7faa..fbfe51e 100644 --- a/apps/project/types.py +++ b/apps/project/types.py @@ -35,7 +35,7 @@ def get_queryset(_, queryset: models.QuerySet | None, info: Info): @strawberry_django.type(Project) class ProjectType(UserResourceTypeMixin): id: strawberry.ID - client_id: strawberry.ID + project_client_id: strawberry.ID contractor_id: strawberry.ID name = string_field(Project.name) @@ -46,8 +46,8 @@ def get_queryset(_, queryset: models.QuerySet | None, info: Info): return get_queryset_for_model(Project, queryset) @strawberry_django.field - async def client(self, root: Project, info: Info) -> ClientType: - return await info.context.dl.project.load_client.load(root.client_id) + async def project_client(self, root: Project, info: Info) -> ClientType: + return await info.context.dl.project.load_client.load(root.project_client_id) @strawberry_django.field async def contractor(self, root: Project, info: Info) -> ContractorType: diff --git a/apps/track/migrations/0008_alter_timeentry_options.py b/apps/track/migrations/0008_alter_timeentry_options.py new file mode 100644 index 0000000..39b8bc8 --- /dev/null +++ b/apps/track/migrations/0008_alter_timeentry_options.py @@ -0,0 +1,17 @@ +# Generated by Django 4.2.14 on 2024-08-07 05:12 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("track", "0007_remove_timeentry_is_done_timeentry_status"), + ] + + operations = [ + migrations.AlterModelOptions( + name="timeentry", + options={"verbose_name": "time entry", "verbose_name_plural": "time entries"}, + ), + ] diff --git a/apps/track/models.py b/apps/track/models.py index 022929e..01aa04e 100644 --- a/apps/track/models.py +++ b/apps/track/models.py @@ -61,3 +61,7 @@ class Status(models.IntegerChoices): user_id: int task_id: int + + class Meta: # type: ignore[reportIncompatibleVariab] + verbose_name = _("time entry") + verbose_name_plural = _("time entries") diff --git a/apps/track/tests/test_mutations.py b/apps/track/tests/test_mutations.py index d3189e1..b903436 100644 --- a/apps/track/tests/test_mutations.py +++ b/apps/track/tests/test_mutations.py @@ -50,7 +50,7 @@ def setUpClass(cls): cls.contractor = ContractorFactory.create(**cls.ur_kwargs) cls.project = ProjectFactory.create( - client=cls.client, + project_client=cls.client, contractor=cls.contractor, **cls.ur_kwargs, ) diff --git a/apps/track/tests/test_queries.py b/apps/track/tests/test_queries.py index 7b2b3bc..db95b48 100644 --- a/apps/track/tests/test_queries.py +++ b/apps/track/tests/test_queries.py @@ -81,7 +81,7 @@ def setUpClass(cls): cls.contractor = ContractorFactory.create(**cls.ur_kwargs) cls.project = ProjectFactory.create( - client=cls.client, + project_client=cls.client, contractor=cls.contractor, **cls.ur_kwargs, ) diff --git a/schema.graphql b/schema.graphql index fab24a9..4b54157 100644 --- a/schema.graphql +++ b/schema.graphql @@ -324,7 +324,7 @@ type PrivateQuery { input ProjectFilter { id: IDBaseFilterLookup - client: DjangoModelFilterInput + projectClient: DjangoModelFilterInput contractor: DjangoModelFilterInput AND: ProjectFilter OR: ProjectFilter @@ -343,11 +343,11 @@ type ProjectType implements UserResourceTypeMixin { createdBy: UserType! modifiedBy: UserType! id: ID! - clientId: ID! + projectClientId: ID! contractorId: ID! name: String! description: String - client: ClientType! + projectClient: ClientType! contractor: ContractorType! } From 8673f301ac6ca3549d1092a7c4ab425f9a4bd4a7 Mon Sep 17 00:00:00 2001 From: thenav56 Date: Wed, 7 Aug 2024 22:19:00 +0545 Subject: [PATCH 15/51] Preserve TimeEntry client_id data in database --- apps/common/serializers.py | 19 +++++++++++++++++-- .../migrations/0009_timeentry_client_id.py | 18 ++++++++++++++++++ apps/track/models.py | 4 ++++ apps/track/tests/test_mutations.py | 2 ++ 4 files changed, 41 insertions(+), 2 deletions(-) create mode 100644 apps/track/migrations/0009_timeentry_client_id.py diff --git a/apps/common/serializers.py b/apps/common/serializers.py index cfa11f3..58287fc 100644 --- a/apps/common/serializers.py +++ b/apps/common/serializers.py @@ -1,3 +1,4 @@ +from django.core.exceptions import FieldDoesNotExist from rest_framework import serializers from main.caches import CacheKey, local_cache @@ -39,8 +40,22 @@ def get_cache_key(instance, request): instance_id=instance.pk, ) + def _get_temp_client_id(self, validated_data): + """ + If client_id is defined at model, then preserve the data else pop from validated_data + """ + try: + self.Meta.model._meta.get_field("client_id") # type: ignore[reportGeneralTypeIssues] + # We return None here if Model have a field `client_id` + return None + # TODO: Check with basic if/elase instead of try/except + except FieldDoesNotExist: + # We remove `client_id` from validated_data and return temp client_id + # If we don't remove `client_id` from validated_data, then serializer will throw error on update/create + return validated_data.pop("client_id", None) + def create(self, validated_data): - temp_client_id = validated_data.pop("client_id", None) + temp_client_id = self._get_temp_client_id(validated_data) instance = super().create(validated_data) if temp_client_id: instance.client_id = temp_client_id @@ -48,7 +63,7 @@ def create(self, validated_data): return instance def update(self, instance, validated_data): - temp_client_id = validated_data.pop("client_id", None) + temp_client_id = self._get_temp_client_id(validated_data) instance = super().update(instance, validated_data) if temp_client_id: instance.client_id = temp_client_id diff --git a/apps/track/migrations/0009_timeentry_client_id.py b/apps/track/migrations/0009_timeentry_client_id.py new file mode 100644 index 0000000..da18cb2 --- /dev/null +++ b/apps/track/migrations/0009_timeentry_client_id.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.14 on 2024-08-07 16:32 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("track", "0008_alter_timeentry_options"), + ] + + operations = [ + migrations.AddField( + model_name="timeentry", + name="client_id", + field=models.CharField(blank=True, max_length=26, null=True), + ), + ] diff --git a/apps/track/models.py b/apps/track/models.py index 01aa04e..7e376cd 100644 --- a/apps/track/models.py +++ b/apps/track/models.py @@ -53,6 +53,10 @@ class Status(models.IntegerChoices): type = models.PositiveSmallIntegerField(choices=Type.choices) status = models.PositiveSmallIntegerField(choices=Status.choices) + # NOTE: client_id persisted as ULID, but no validation done on server-side + # Uniqueness is required at per-user per-day level + # Due to which uniqueness is not something we need to check at DB level + client_id = models.CharField(max_length=26, null=True, blank=True) start_time = models.TimeField(null=True, blank=True) description = models.TextField(blank=True) diff --git a/apps/track/tests/test_mutations.py b/apps/track/tests/test_mutations.py index b903436..55caddb 100644 --- a/apps/track/tests/test_mutations.py +++ b/apps/track/tests/test_mutations.py @@ -376,3 +376,5 @@ def test_bulk_time_entry_mix(self): assert set(self._get_ids(needs_to_be_preserved)).issubset( current_time_entry_ids ), "All other user's time_entry should't be deleted" + + # TODO: Add mutation to check client_id preserve for TimeEntry From 6c40bc0880d7c384c511835d0246d5b62941f570 Mon Sep 17 00:00:00 2001 From: thenav56 Date: Thu, 8 Aug 2024 16:51:31 +0545 Subject: [PATCH 16/51] Add TimeEntry created_at to track TODO tasks --- .../migrations/0010_timeentry_created_at.py | 20 +++++++++++++++++++ apps/track/models.py | 1 + apps/track/tests/test_mutations.py | 1 + apps/track/types.py | 1 + schema.graphql | 1 + 5 files changed, 24 insertions(+) create mode 100644 apps/track/migrations/0010_timeentry_created_at.py diff --git a/apps/track/migrations/0010_timeentry_created_at.py b/apps/track/migrations/0010_timeentry_created_at.py new file mode 100644 index 0000000..0221904 --- /dev/null +++ b/apps/track/migrations/0010_timeentry_created_at.py @@ -0,0 +1,20 @@ +# Generated by Django 4.2.14 on 2024-08-08 11:04 + +import django.utils.timezone +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("track", "0009_timeentry_client_id"), + ] + + operations = [ + migrations.AddField( + model_name="timeentry", + name="created_at", + field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now), + preserve_default=False, + ), + ] diff --git a/apps/track/models.py b/apps/track/models.py index 7e376cd..658b67d 100644 --- a/apps/track/models.py +++ b/apps/track/models.py @@ -50,6 +50,7 @@ class Status(models.IntegerChoices): user = models.ForeignKey(User, on_delete=models.PROTECT, related_name="+") task = models.ForeignKey(Task, on_delete=models.PROTECT, related_name="+") date = models.DateField() + created_at = models.DateTimeField(auto_now_add=True) # To track TODO tasks type = models.PositiveSmallIntegerField(choices=Type.choices) status = models.PositiveSmallIntegerField(choices=Status.choices) diff --git a/apps/track/tests/test_mutations.py b/apps/track/tests/test_mutations.py index 55caddb..e03daf2 100644 --- a/apps/track/tests/test_mutations.py +++ b/apps/track/tests/test_mutations.py @@ -378,3 +378,4 @@ def test_bulk_time_entry_mix(self): ), "All other user's time_entry should't be deleted" # TODO: Add mutation to check client_id preserve for TimeEntry + # TODO: Test createdAt field as well diff --git a/apps/track/types.py b/apps/track/types.py index caf4084..f7cd0f7 100644 --- a/apps/track/types.py +++ b/apps/track/types.py @@ -60,6 +60,7 @@ class TimeEntryType(ClientIdMixin): user_id: strawberry.ID task_id: strawberry.ID start_time: strawberry.auto + created_at: strawberry.auto duration: TimeDuration | None status = enum_field(TimeEntry.status) diff --git a/schema.graphql b/schema.graphql index 4b54157..f9a040f 100644 --- a/schema.graphql +++ b/schema.graphql @@ -520,6 +520,7 @@ type TimeEntryType implements ClientIdMixin { userId: ID! taskId: ID! startTime: Time + createdAt: DateTime! duration: TimeDuration status: TimeEntryStatusEnum! statusDisplay: String! From 657c5ab9f333a26fe2d5f2d2f24089f8da15c4cf Mon Sep 17 00:00:00 2001 From: thenav56 Date: Thu, 8 Aug 2024 17:41:18 +0545 Subject: [PATCH 17/51] Add /health-check Upgrade packages --- main/settings.py | 16 +++ main/urls.py | 3 +- poetry.lock | 362 +++++++++++++++++++++++++++-------------------- pyproject.toml | 2 + 4 files changed, 232 insertions(+), 151 deletions(-) diff --git a/main/settings.py b/main/settings.py index 73da487..7fea5a8 100644 --- a/main/settings.py +++ b/main/settings.py @@ -128,6 +128,14 @@ "django_premailer", "storages", "corsheaders", + # - Health-check + "health_check", # required + "health_check.db", # stock Django health checkers + "health_check.cache", + "health_check.storage", + "health_check.contrib.migrations", + "health_check.contrib.psutil", # disk and memory utilization; requires psutil + "health_check.contrib.redis", # requires Redis broker # Internal apps "apps.common", # Common "apps.standup", @@ -437,3 +445,11 @@ # TODO: We need these lines below to allow the Google sign in popup to work. SECURE_REFERRER_POLICY = "no-referrer-when-downgrade" SECURE_CROSS_ORIGIN_OPENER_POLICY = "same-origin-allow-popups" + +# Health check +REDIS_URL = DJANGO_CACHE_REDIS_URL +HEALTHCHECK_CACHE_KEY = "alert_hub_healthcheck_key" +HEALTH_CHECK = { + "DISK_USAGE_MAX": 80, # percent + "MEMORY_MIN": 100, # in MB +} diff --git a/main/urls.py b/main/urls.py index 160794a..bbf2ea6 100644 --- a/main/urls.py +++ b/main/urls.py @@ -1,7 +1,7 @@ from django.conf import settings from django.conf.urls.static import static from django.contrib import admin -from django.urls import path +from django.urls import include, path from apps.common.views import dev_sign_in, google_oauth from main.graphql.schema import CustomAsyncGraphQLView @@ -9,6 +9,7 @@ urlpatterns = [ path("admin/", admin.site.urls, name="admin"), + path("health-check/", include("health_check.urls")), # path('health-check/', include('health_check.urls')), path( "graphql/", diff --git a/poetry.lock b/poetry.lock index 8ad93f1..c7cad54 100644 --- a/poetry.lock +++ b/poetry.lock @@ -48,22 +48,22 @@ test = ["astroid (>=1,<2)", "astroid (>=2,<4)", "pytest"] [[package]] name = "attrs" -version = "23.2.0" +version = "24.2.0" description = "Classes Without Boilerplate" optional = false python-versions = ">=3.7" files = [ - {file = "attrs-23.2.0-py3-none-any.whl", hash = "sha256:99b87a485a5820b23b879f04c2305b44b951b502fd64be915879d77a7e8fc6f1"}, - {file = "attrs-23.2.0.tar.gz", hash = "sha256:935dc3b529c262f6cf76e50877d35a4bd3c1de194fd41f47a2b7ae8f19971f30"}, + {file = "attrs-24.2.0-py3-none-any.whl", hash = "sha256:81921eb96de3191c8258c199618104dd27ac608d9366f5e35d011eae1867ede2"}, + {file = "attrs-24.2.0.tar.gz", hash = "sha256:5cfb1b9148b5b086569baec03f20d7b6bf3bcacc9a42bebf87ffaaca362f6346"}, ] [package.extras] -cov = ["attrs[tests]", "coverage[toml] (>=5.3)"] -dev = ["attrs[tests]", "pre-commit"] -docs = ["furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier", "zope-interface"] -tests = ["attrs[tests-no-zope]", "zope-interface"] -tests-mypy = ["mypy (>=1.6)", "pytest-mypy-plugins"] -tests-no-zope = ["attrs[tests-mypy]", "cloudpickle", "hypothesis", "pympler", "pytest (>=4.3.0)", "pytest-xdist[psutil]"] +benchmark = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-codspeed", "pytest-mypy-plugins", "pytest-xdist[psutil]"] +cov = ["cloudpickle", "coverage[toml] (>=5.3)", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] +dev = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pre-commit", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] +docs = ["cogapp", "furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier (<24.7)"] +tests = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] +tests-mypy = ["mypy (>=1.11.1)", "pytest-mypy-plugins"] [[package]] name = "aws-sns-message-validator" @@ -94,17 +94,17 @@ files = [ [[package]] name = "boto3" -version = "1.34.149" +version = "1.34.156" description = "The AWS SDK for Python" optional = false python-versions = ">=3.8" files = [ - {file = "boto3-1.34.149-py3-none-any.whl", hash = "sha256:11edeeacdd517bda3b7615b754d8440820cdc9ddd66794cc995a9693ddeaa3be"}, - {file = "boto3-1.34.149.tar.gz", hash = "sha256:f4e6489ba9dc7fb37d53e0e82dbc97f2cb0a4969ef3970e2c88b8f94023ae81a"}, + {file = "boto3-1.34.156-py3-none-any.whl", hash = "sha256:cbbd453270b8ce94ef9da60dfbb6f9ceeb3eeee226b635aa9ec44b1def98cc96"}, + {file = "boto3-1.34.156.tar.gz", hash = "sha256:b33e9a8f8be80d3053b8418836a7c1900410b23a30c7cb040927d601a1082e68"}, ] [package.dependencies] -botocore = ">=1.34.149,<1.35.0" +botocore = ">=1.34.156,<1.35.0" jmespath = ">=0.7.1,<2.0.0" s3transfer = ">=0.10.0,<0.11.0" @@ -113,13 +113,13 @@ crt = ["botocore[crt] (>=1.21.0,<2.0a0)"] [[package]] name = "botocore" -version = "1.34.149" +version = "1.34.156" description = "Low-level, data-driven core of boto 3." optional = false python-versions = ">=3.8" files = [ - {file = "botocore-1.34.149-py3-none-any.whl", hash = "sha256:ae6c4be52eeee96f68c116b27d252bab069cd046d61a17cfe8e9da411cf22906"}, - {file = "botocore-1.34.149.tar.gz", hash = "sha256:2e1eb5ef40102a3d796bb3dd05f2ac5e8fb43fe1ff114b4f6d33153437f5a372"}, + {file = "botocore-1.34.156-py3-none-any.whl", hash = "sha256:c48f8c8996216dfdeeb0aa6d3c0f2c7ae25234766434a2ea3e57bdc08494bdda"}, + {file = "botocore-1.34.156.tar.gz", hash = "sha256:5d1478c41ab9681e660b3322432fe09c4055759c317984b7b8d3af9557ff769a"}, ] [package.dependencies] @@ -128,7 +128,7 @@ python-dateutil = ">=2.1,<3.0.0" urllib3 = {version = ">=1.25.4,<2.2.0 || >2.2.0,<3", markers = "python_version >= \"3.10\""} [package.extras] -crt = ["awscrt (==0.20.11)"] +crt = ["awscrt (==0.21.2)"] [[package]] name = "cachetools" @@ -248,63 +248,78 @@ files = [ [[package]] name = "cffi" -version = "1.16.0" +version = "1.17.0" description = "Foreign Function Interface for Python calling C code." optional = false python-versions = ">=3.8" files = [ - {file = "cffi-1.16.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6b3d6606d369fc1da4fd8c357d026317fbb9c9b75d36dc16e90e84c26854b088"}, - {file = "cffi-1.16.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ac0f5edd2360eea2f1daa9e26a41db02dd4b0451b48f7c318e217ee092a213e9"}, - {file = "cffi-1.16.0-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7e61e3e4fa664a8588aa25c883eab612a188c725755afff6289454d6362b9673"}, - {file = "cffi-1.16.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a72e8961a86d19bdb45851d8f1f08b041ea37d2bd8d4fd19903bc3083d80c896"}, - {file = "cffi-1.16.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b50bf3f55561dac5438f8e70bfcdfd74543fd60df5fa5f62d94e5867deca684"}, - {file = "cffi-1.16.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7651c50c8c5ef7bdb41108b7b8c5a83013bfaa8a935590c5d74627c047a583c7"}, - {file = "cffi-1.16.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e4108df7fe9b707191e55f33efbcb2d81928e10cea45527879a4749cbe472614"}, - {file = "cffi-1.16.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:32c68ef735dbe5857c810328cb2481e24722a59a2003018885514d4c09af9743"}, - {file = "cffi-1.16.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:673739cb539f8cdaa07d92d02efa93c9ccf87e345b9a0b556e3ecc666718468d"}, - {file = "cffi-1.16.0-cp310-cp310-win32.whl", hash = "sha256:9f90389693731ff1f659e55c7d1640e2ec43ff725cc61b04b2f9c6d8d017df6a"}, - {file = "cffi-1.16.0-cp310-cp310-win_amd64.whl", hash = "sha256:e6024675e67af929088fda399b2094574609396b1decb609c55fa58b028a32a1"}, - {file = "cffi-1.16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b84834d0cf97e7d27dd5b7f3aca7b6e9263c56308ab9dc8aae9784abb774d404"}, - {file = "cffi-1.16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1b8ebc27c014c59692bb2664c7d13ce7a6e9a629be20e54e7271fa696ff2b417"}, - {file = "cffi-1.16.0-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ee07e47c12890ef248766a6e55bd38ebfb2bb8edd4142d56db91b21ea68b7627"}, - {file = "cffi-1.16.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8a9d3ebe49f084ad71f9269834ceccbf398253c9fac910c4fd7053ff1386936"}, - {file = "cffi-1.16.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e70f54f1796669ef691ca07d046cd81a29cb4deb1e5f942003f401c0c4a2695d"}, - {file = "cffi-1.16.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5bf44d66cdf9e893637896c7faa22298baebcd18d1ddb6d2626a6e39793a1d56"}, - {file = "cffi-1.16.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7b78010e7b97fef4bee1e896df8a4bbb6712b7f05b7ef630f9d1da00f6444d2e"}, - {file = "cffi-1.16.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:c6a164aa47843fb1b01e941d385aab7215563bb8816d80ff3a363a9f8448a8dc"}, - {file = "cffi-1.16.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e09f3ff613345df5e8c3667da1d918f9149bd623cd9070c983c013792a9a62eb"}, - {file = "cffi-1.16.0-cp311-cp311-win32.whl", hash = "sha256:2c56b361916f390cd758a57f2e16233eb4f64bcbeee88a4881ea90fca14dc6ab"}, - {file = "cffi-1.16.0-cp311-cp311-win_amd64.whl", hash = "sha256:db8e577c19c0fda0beb7e0d4e09e0ba74b1e4c092e0e40bfa12fe05b6f6d75ba"}, - {file = "cffi-1.16.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:fa3a0128b152627161ce47201262d3140edb5a5c3da88d73a1b790a959126956"}, - {file = "cffi-1.16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:68e7c44931cc171c54ccb702482e9fc723192e88d25a0e133edd7aff8fcd1f6e"}, - {file = "cffi-1.16.0-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:abd808f9c129ba2beda4cfc53bde801e5bcf9d6e0f22f095e45327c038bfe68e"}, - {file = "cffi-1.16.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:88e2b3c14bdb32e440be531ade29d3c50a1a59cd4e51b1dd8b0865c54ea5d2e2"}, - {file = "cffi-1.16.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fcc8eb6d5902bb1cf6dc4f187ee3ea80a1eba0a89aba40a5cb20a5087d961357"}, - {file = "cffi-1.16.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b7be2d771cdba2942e13215c4e340bfd76398e9227ad10402a8767ab1865d2e6"}, - {file = "cffi-1.16.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e715596e683d2ce000574bae5d07bd522c781a822866c20495e52520564f0969"}, - {file = "cffi-1.16.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:2d92b25dbf6cae33f65005baf472d2c245c050b1ce709cc4588cdcdd5495b520"}, - {file = "cffi-1.16.0-cp312-cp312-win32.whl", hash = "sha256:b2ca4e77f9f47c55c194982e10f058db063937845bb2b7a86c84a6cfe0aefa8b"}, - {file = "cffi-1.16.0-cp312-cp312-win_amd64.whl", hash = "sha256:68678abf380b42ce21a5f2abde8efee05c114c2fdb2e9eef2efdb0257fba1235"}, - {file = "cffi-1.16.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:0c9ef6ff37e974b73c25eecc13952c55bceed9112be2d9d938ded8e856138bcc"}, - {file = "cffi-1.16.0-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a09582f178759ee8128d9270cd1344154fd473bb77d94ce0aeb2a93ebf0feaf0"}, - {file = "cffi-1.16.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e760191dd42581e023a68b758769e2da259b5d52e3103c6060ddc02c9edb8d7b"}, - {file = "cffi-1.16.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:80876338e19c951fdfed6198e70bc88f1c9758b94578d5a7c4c91a87af3cf31c"}, - {file = "cffi-1.16.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a6a14b17d7e17fa0d207ac08642c8820f84f25ce17a442fd15e27ea18d67c59b"}, - {file = "cffi-1.16.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6602bc8dc6f3a9e02b6c22c4fc1e47aa50f8f8e6d3f78a5e16ac33ef5fefa324"}, - {file = "cffi-1.16.0-cp38-cp38-win32.whl", hash = "sha256:131fd094d1065b19540c3d72594260f118b231090295d8c34e19a7bbcf2e860a"}, - {file = "cffi-1.16.0-cp38-cp38-win_amd64.whl", hash = "sha256:31d13b0f99e0836b7ff893d37af07366ebc90b678b6664c955b54561fc36ef36"}, - {file = "cffi-1.16.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:582215a0e9adbe0e379761260553ba11c58943e4bbe9c36430c4ca6ac74b15ed"}, - {file = "cffi-1.16.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:b29ebffcf550f9da55bec9e02ad430c992a87e5f512cd63388abb76f1036d8d2"}, - {file = "cffi-1.16.0-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dc9b18bf40cc75f66f40a7379f6a9513244fe33c0e8aa72e2d56b0196a7ef872"}, - {file = "cffi-1.16.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9cb4a35b3642fc5c005a6755a5d17c6c8b6bcb6981baf81cea8bfbc8903e8ba8"}, - {file = "cffi-1.16.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b86851a328eedc692acf81fb05444bdf1891747c25af7529e39ddafaf68a4f3f"}, - {file = "cffi-1.16.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c0f31130ebc2d37cdd8e44605fb5fa7ad59049298b3f745c74fa74c62fbfcfc4"}, - {file = "cffi-1.16.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f8e709127c6c77446a8c0a8c8bf3c8ee706a06cd44b1e827c3e6a2ee6b8c098"}, - {file = "cffi-1.16.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:748dcd1e3d3d7cd5443ef03ce8685043294ad6bd7c02a38d1bd367cfd968e000"}, - {file = "cffi-1.16.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:8895613bcc094d4a1b2dbe179d88d7fb4a15cee43c052e8885783fac397d91fe"}, - {file = "cffi-1.16.0-cp39-cp39-win32.whl", hash = "sha256:ed86a35631f7bfbb28e108dd96773b9d5a6ce4811cf6ea468bb6a359b256b1e4"}, - {file = "cffi-1.16.0-cp39-cp39-win_amd64.whl", hash = "sha256:3686dffb02459559c74dd3d81748269ffb0eb027c39a6fc99502de37d501faa8"}, - {file = "cffi-1.16.0.tar.gz", hash = "sha256:bcb3ef43e58665bbda2fb198698fcae6776483e0c4a631aa5647806c25e02cc0"}, + {file = "cffi-1.17.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f9338cc05451f1942d0d8203ec2c346c830f8e86469903d5126c1f0a13a2bcbb"}, + {file = "cffi-1.17.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a0ce71725cacc9ebf839630772b07eeec220cbb5f03be1399e0457a1464f8e1a"}, + {file = "cffi-1.17.0-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c815270206f983309915a6844fe994b2fa47e5d05c4c4cef267c3b30e34dbe42"}, + {file = "cffi-1.17.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d6bdcd415ba87846fd317bee0774e412e8792832e7805938987e4ede1d13046d"}, + {file = "cffi-1.17.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8a98748ed1a1df4ee1d6f927e151ed6c1a09d5ec21684de879c7ea6aa96f58f2"}, + {file = "cffi-1.17.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0a048d4f6630113e54bb4b77e315e1ba32a5a31512c31a273807d0027a7e69ab"}, + {file = "cffi-1.17.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:24aa705a5f5bd3a8bcfa4d123f03413de5d86e497435693b638cbffb7d5d8a1b"}, + {file = "cffi-1.17.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:856bf0924d24e7f93b8aee12a3a1095c34085600aa805693fb7f5d1962393206"}, + {file = "cffi-1.17.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:4304d4416ff032ed50ad6bb87416d802e67139e31c0bde4628f36a47a3164bfa"}, + {file = "cffi-1.17.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:331ad15c39c9fe9186ceaf87203a9ecf5ae0ba2538c9e898e3a6967e8ad3db6f"}, + {file = "cffi-1.17.0-cp310-cp310-win32.whl", hash = "sha256:669b29a9eca6146465cc574659058ed949748f0809a2582d1f1a324eb91054dc"}, + {file = "cffi-1.17.0-cp310-cp310-win_amd64.whl", hash = "sha256:48b389b1fd5144603d61d752afd7167dfd205973a43151ae5045b35793232aa2"}, + {file = "cffi-1.17.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c5d97162c196ce54af6700949ddf9409e9833ef1003b4741c2b39ef46f1d9720"}, + {file = "cffi-1.17.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5ba5c243f4004c750836f81606a9fcb7841f8874ad8f3bf204ff5e56332b72b9"}, + {file = "cffi-1.17.0-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bb9333f58fc3a2296fb1d54576138d4cf5d496a2cc118422bd77835e6ae0b9cb"}, + {file = "cffi-1.17.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:435a22d00ec7d7ea533db494da8581b05977f9c37338c80bc86314bec2619424"}, + {file = "cffi-1.17.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d1df34588123fcc88c872f5acb6f74ae59e9d182a2707097f9e28275ec26a12d"}, + {file = "cffi-1.17.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:df8bb0010fdd0a743b7542589223a2816bdde4d94bb5ad67884348fa2c1c67e8"}, + {file = "cffi-1.17.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a8b5b9712783415695663bd463990e2f00c6750562e6ad1d28e072a611c5f2a6"}, + {file = "cffi-1.17.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ffef8fd58a36fb5f1196919638f73dd3ae0db1a878982b27a9a5a176ede4ba91"}, + {file = "cffi-1.17.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:4e67d26532bfd8b7f7c05d5a766d6f437b362c1bf203a3a5ce3593a645e870b8"}, + {file = "cffi-1.17.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:45f7cd36186db767d803b1473b3c659d57a23b5fa491ad83c6d40f2af58e4dbb"}, + {file = "cffi-1.17.0-cp311-cp311-win32.whl", hash = "sha256:a9015f5b8af1bb6837a3fcb0cdf3b874fe3385ff6274e8b7925d81ccaec3c5c9"}, + {file = "cffi-1.17.0-cp311-cp311-win_amd64.whl", hash = "sha256:b50aaac7d05c2c26dfd50c3321199f019ba76bb650e346a6ef3616306eed67b0"}, + {file = "cffi-1.17.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:aec510255ce690d240f7cb23d7114f6b351c733a74c279a84def763660a2c3bc"}, + {file = "cffi-1.17.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2770bb0d5e3cc0e31e7318db06efcbcdb7b31bcb1a70086d3177692a02256f59"}, + {file = "cffi-1.17.0-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:db9a30ec064129d605d0f1aedc93e00894b9334ec74ba9c6bdd08147434b33eb"}, + {file = "cffi-1.17.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a47eef975d2b8b721775a0fa286f50eab535b9d56c70a6e62842134cf7841195"}, + {file = "cffi-1.17.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f3e0992f23bbb0be00a921eae5363329253c3b86287db27092461c887b791e5e"}, + {file = "cffi-1.17.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6107e445faf057c118d5050560695e46d272e5301feffda3c41849641222a828"}, + {file = "cffi-1.17.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eb862356ee9391dc5a0b3cbc00f416b48c1b9a52d252d898e5b7696a5f9fe150"}, + {file = "cffi-1.17.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c1c13185b90bbd3f8b5963cd8ce7ad4ff441924c31e23c975cb150e27c2bf67a"}, + {file = "cffi-1.17.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:17c6d6d3260c7f2d94f657e6872591fe8733872a86ed1345bda872cfc8c74885"}, + {file = "cffi-1.17.0-cp312-cp312-win32.whl", hash = "sha256:c3b8bd3133cd50f6b637bb4322822c94c5ce4bf0d724ed5ae70afce62187c492"}, + {file = "cffi-1.17.0-cp312-cp312-win_amd64.whl", hash = "sha256:dca802c8db0720ce1c49cce1149ff7b06e91ba15fa84b1d59144fef1a1bc7ac2"}, + {file = "cffi-1.17.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6ce01337d23884b21c03869d2f68c5523d43174d4fc405490eb0091057943118"}, + {file = "cffi-1.17.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cab2eba3830bf4f6d91e2d6718e0e1c14a2f5ad1af68a89d24ace0c6b17cced7"}, + {file = "cffi-1.17.0-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:14b9cbc8f7ac98a739558eb86fabc283d4d564dafed50216e7f7ee62d0d25377"}, + {file = "cffi-1.17.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b00e7bcd71caa0282cbe3c90966f738e2db91e64092a877c3ff7f19a1628fdcb"}, + {file = "cffi-1.17.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:41f4915e09218744d8bae14759f983e466ab69b178de38066f7579892ff2a555"}, + {file = "cffi-1.17.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e4760a68cab57bfaa628938e9c2971137e05ce48e762a9cb53b76c9b569f1204"}, + {file = "cffi-1.17.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:011aff3524d578a9412c8b3cfaa50f2c0bd78e03eb7af7aa5e0df59b158efb2f"}, + {file = "cffi-1.17.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:a003ac9edc22d99ae1286b0875c460351f4e101f8c9d9d2576e78d7e048f64e0"}, + {file = "cffi-1.17.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ef9528915df81b8f4c7612b19b8628214c65c9b7f74db2e34a646a0a2a0da2d4"}, + {file = "cffi-1.17.0-cp313-cp313-win32.whl", hash = "sha256:70d2aa9fb00cf52034feac4b913181a6e10356019b18ef89bc7c12a283bf5f5a"}, + {file = "cffi-1.17.0-cp313-cp313-win_amd64.whl", hash = "sha256:b7b6ea9e36d32582cda3465f54c4b454f62f23cb083ebc7a94e2ca6ef011c3a7"}, + {file = "cffi-1.17.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:964823b2fc77b55355999ade496c54dde161c621cb1f6eac61dc30ed1b63cd4c"}, + {file = "cffi-1.17.0-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:516a405f174fd3b88829eabfe4bb296ac602d6a0f68e0d64d5ac9456194a5b7e"}, + {file = "cffi-1.17.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dec6b307ce928e8e112a6bb9921a1cb00a0e14979bf28b98e084a4b8a742bd9b"}, + {file = "cffi-1.17.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e4094c7b464cf0a858e75cd14b03509e84789abf7b79f8537e6a72152109c76e"}, + {file = "cffi-1.17.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2404f3de742f47cb62d023f0ba7c5a916c9c653d5b368cc966382ae4e57da401"}, + {file = "cffi-1.17.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3aa9d43b02a0c681f0bfbc12d476d47b2b2b6a3f9287f11ee42989a268a1833c"}, + {file = "cffi-1.17.0-cp38-cp38-win32.whl", hash = "sha256:0bb15e7acf8ab35ca8b24b90af52c8b391690ef5c4aec3d31f38f0d37d2cc499"}, + {file = "cffi-1.17.0-cp38-cp38-win_amd64.whl", hash = "sha256:93a7350f6706b31f457c1457d3a3259ff9071a66f312ae64dc024f049055f72c"}, + {file = "cffi-1.17.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:1a2ddbac59dc3716bc79f27906c010406155031a1c801410f1bafff17ea304d2"}, + {file = "cffi-1.17.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:6327b572f5770293fc062a7ec04160e89741e8552bf1c358d1a23eba68166759"}, + {file = "cffi-1.17.0-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dbc183e7bef690c9abe5ea67b7b60fdbca81aa8da43468287dae7b5c046107d4"}, + {file = "cffi-1.17.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5bdc0f1f610d067c70aa3737ed06e2726fd9d6f7bfee4a351f4c40b6831f4e82"}, + {file = "cffi-1.17.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6d872186c1617d143969defeadac5a904e6e374183e07977eedef9c07c8953bf"}, + {file = "cffi-1.17.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0d46ee4764b88b91f16661a8befc6bfb24806d885e27436fdc292ed7e6f6d058"}, + {file = "cffi-1.17.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f76a90c345796c01d85e6332e81cab6d70de83b829cf1d9762d0a3da59c7932"}, + {file = "cffi-1.17.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0e60821d312f99d3e1569202518dddf10ae547e799d75aef3bca3a2d9e8ee693"}, + {file = "cffi-1.17.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:eb09b82377233b902d4c3fbeeb7ad731cdab579c6c6fda1f763cd779139e47c3"}, + {file = "cffi-1.17.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:24658baf6224d8f280e827f0a50c46ad819ec8ba380a42448e24459daf809cf4"}, + {file = "cffi-1.17.0-cp39-cp39-win32.whl", hash = "sha256:0fdacad9e0d9fc23e519efd5ea24a70348305e8d7d85ecbb1a5fa66dc834e7fb"}, + {file = "cffi-1.17.0-cp39-cp39-win_amd64.whl", hash = "sha256:7cbc78dc018596315d4e7841c8c3a7ae31cc4d638c9b627f87d52e8abaaf2d29"}, + {file = "cffi-1.17.0.tar.gz", hash = "sha256:f3157624b7558b914cb039fd1af735e5e8049a87c817cc215109ad1c8779df76"}, ] [package.dependencies] @@ -587,13 +602,13 @@ files = [ [[package]] name = "django" -version = "4.2.14" +version = "4.2.15" description = "A high-level Python web framework that encourages rapid development and clean, pragmatic design." optional = false python-versions = ">=3.8" files = [ - {file = "Django-4.2.14-py3-none-any.whl", hash = "sha256:3ec32bc2c616ab02834b9cac93143a7dc1cdcd5b822d78ac95fc20a38c534240"}, - {file = "Django-4.2.14.tar.gz", hash = "sha256:fc6919875a6226c7ffcae1a7d51e0f2ceaf6f160393180818f6c95f51b1e7b96"}, + {file = "Django-4.2.15-py3-none-any.whl", hash = "sha256:61ee4a130efb8c451ef3467c67ca99fdce400fedd768634efc86a68c18d80d30"}, + {file = "Django-4.2.15.tar.gz", hash = "sha256:c77f926b81129493961e19c0e02188f8d07c112a1162df69bfab178ae447f94a"}, ] [package.dependencies] @@ -650,6 +665,24 @@ develop = ["coverage[toml] (>=5.0a4)", "furo (>=2021.8.17b43,<2021.9.dev0)", "py docs = ["furo (>=2021.8.17b43,<2021.9.dev0)", "sphinx (>=3.5.0)", "sphinx-notfound-page"] testing = ["coverage[toml] (>=5.0a4)", "pytest (>=4.6.11)"] +[[package]] +name = "django-health-check" +version = "3.18.3" +description = "Run checks on services like databases, queue servers, celery processes, etc." +optional = false +python-versions = ">=3.8" +files = [ + {file = "django_health_check-3.18.3-py2.py3-none-any.whl", hash = "sha256:f5f58762b80bdf7b12fad724761993d6e83540f97e2c95c42978f187e452fa07"}, + {file = "django_health_check-3.18.3.tar.gz", hash = "sha256:18b75daca4551c69a43f804f9e41e23f5f5fb9efd06cf6a313b3d5031bb87bd0"}, +] + +[package.dependencies] +django = ">=2.2" + +[package.extras] +docs = ["sphinx"] +test = ["boto3", "celery", "django-storages", "pytest", "pytest-cov", "pytest-django", "redis"] + [[package]] name = "django-premailer" version = "0.2.0" @@ -762,13 +795,13 @@ compatible-mypy = ["mypy (>=1.6.0,<1.7.0)"] [[package]] name = "django-stubs-ext" -version = "5.0.2" +version = "5.0.4" description = "Monkey-patching and extensions for django-stubs" optional = false python-versions = ">=3.8" files = [ - {file = "django_stubs_ext-5.0.2-py3-none-any.whl", hash = "sha256:8d8efec5a86241266bec94a528fe21258ad90d78c67307f3ae5f36e81de97f12"}, - {file = "django_stubs_ext-5.0.2.tar.gz", hash = "sha256:409c62585d7f996cef5c760e6e27ea3ff29f961c943747e67519c837422cad32"}, + {file = "django_stubs_ext-5.0.4-py3-none-any.whl", hash = "sha256:910cbaff3d1e8e806a5c27d5ddd4088535aae8371ea921b7fd680fdfa5f14e30"}, + {file = "django_stubs_ext-5.0.4.tar.gz", hash = "sha256:85da065224204774208be29c7d02b4482d5a69218a728465c2fbe41725fdc819"}, ] [package.dependencies] @@ -857,13 +890,13 @@ doc = ["Sphinx", "sphinx-rtd-theme", "sphinxcontrib-spelling"] [[package]] name = "faker" -version = "26.0.0" +version = "26.2.0" description = "Faker is a Python package that generates fake data for you." optional = false python-versions = ">=3.8" files = [ - {file = "Faker-26.0.0-py3-none-any.whl", hash = "sha256:886ee28219be96949cd21ecc96c4c742ee1680e77f687b095202c8def1a08f06"}, - {file = "Faker-26.0.0.tar.gz", hash = "sha256:0f60978314973de02c00474c2ae899785a42b2cf4f41b7987e93c132a2b8a4a9"}, + {file = "Faker-26.2.0-py3-none-any.whl", hash = "sha256:7b123090774deff5f2cd3eb92a84dcbbf1e163f30a6d07321b7852c11bfe6a75"}, + {file = "Faker-26.2.0.tar.gz", hash = "sha256:81768de19012147521140f0d8bf5353e501ac42c1065d25e0cac455d3615c0a7"}, ] [package.dependencies] @@ -894,13 +927,13 @@ grpcio-gcp = ["grpcio-gcp (>=0.2.2,<1.0.dev0)"] [[package]] name = "google-api-python-client" -version = "2.138.0" +version = "2.140.0" description = "Google API Client Library for Python" optional = false python-versions = ">=3.7" files = [ - {file = "google_api_python_client-2.138.0-py2.py3-none-any.whl", hash = "sha256:1dd279124e4e77cbda4769ffb4abe7e7c32528ef1e18739320fef2a07b750764"}, - {file = "google_api_python_client-2.138.0.tar.gz", hash = "sha256:31080fbf0e64687876135cc23d1bec1ca3b80d7702177dd17b04131ea889eb70"}, + {file = "google_api_python_client-2.140.0-py2.py3-none-any.whl", hash = "sha256:aeb4bb99e9fdd241473da5ff35464a0658fea0db76fe89c0f8c77ecfc3813404"}, + {file = "google_api_python_client-2.140.0.tar.gz", hash = "sha256:0bb973adccbe66a3d0a70abe4e49b3f2f004d849416bfec38d22b75649d389d8"}, ] [package.dependencies] @@ -912,13 +945,13 @@ uritemplate = ">=3.0.1,<5" [[package]] name = "google-auth" -version = "2.32.0" +version = "2.33.0" description = "Google Authentication Library" optional = false python-versions = ">=3.7" files = [ - {file = "google_auth-2.32.0-py2.py3-none-any.whl", hash = "sha256:53326ea2ebec768070a94bee4e1b9194c9646ea0c2bd72422785bd0f9abfad7b"}, - {file = "google_auth-2.32.0.tar.gz", hash = "sha256:49315be72c55a6a37d62819e3573f6b416aca00721f7e3e31a008d928bf64022"}, + {file = "google_auth-2.33.0-py2.py3-none-any.whl", hash = "sha256:8eff47d0d4a34ab6265c50a106a3362de6a9975bb08998700e389f857e4d39df"}, + {file = "google_auth-2.33.0.tar.gz", hash = "sha256:d6a52342160d7290e334b4d47ba390767e4438ad0d45b7630774533e82655b95"}, ] [package.dependencies] @@ -1091,28 +1124,28 @@ files = [ [[package]] name = "kombu" -version = "5.3.7" +version = "5.4.0" description = "Messaging library for Python." optional = false python-versions = ">=3.8" files = [ - {file = "kombu-5.3.7-py3-none-any.whl", hash = "sha256:5634c511926309c7f9789f1433e9ed402616b56836ef9878f01bd59267b4c7a9"}, - {file = "kombu-5.3.7.tar.gz", hash = "sha256:011c4cd9a355c14a1de8d35d257314a1d2456d52b7140388561acac3cf1a97bf"}, + {file = "kombu-5.4.0-py3-none-any.whl", hash = "sha256:c8dd99820467610b4febbc7a9e8a0d3d7da2d35116b67184418b51cc520ea6b6"}, + {file = "kombu-5.4.0.tar.gz", hash = "sha256:ad200a8dbdaaa2bbc5f26d2ee7d707d9a1fded353a0f4bd751ce8c7d9f449c60"}, ] [package.dependencies] amqp = ">=5.1.1,<6.0.0" -vine = "*" +vine = "5.1.0" [package.extras] azureservicebus = ["azure-servicebus (>=7.10.0)"] azurestoragequeues = ["azure-identity (>=1.12.0)", "azure-storage-queue (>=12.6.0)"] confluentkafka = ["confluent-kafka (>=2.2.0)"] -consul = ["python-consul2"] +consul = ["python-consul2 (==0.1.5)"] librabbitmq = ["librabbitmq (>=2.0.0)"] mongodb = ["pymongo (>=4.1.1)"] -msgpack = ["msgpack"] -pyro = ["pyro4"] +msgpack = ["msgpack (==1.0.8)"] +pyro = ["pyro4 (==4.82)"] qpid = ["qpid-python (>=0.26)", "qpid-tools (>=0.26)"] redis = ["redis (>=4.5.2,!=4.5.5,!=5.0.2)"] slmq = ["softlayer-messaging (>=1.0.3)"] @@ -1291,49 +1324,49 @@ traitlets = "*" [[package]] name = "more-itertools" -version = "10.3.0" +version = "10.4.0" description = "More routines for operating on iterables, beyond itertools" optional = false python-versions = ">=3.8" files = [ - {file = "more-itertools-10.3.0.tar.gz", hash = "sha256:e5d93ef411224fbcef366a6e8ddc4c5781bc6359d43412a65dd5964e46111463"}, - {file = "more_itertools-10.3.0-py3-none-any.whl", hash = "sha256:ea6a02e24a9161e51faad17a8782b92a0df82c12c1c8886fec7f0c3fa1a1b320"}, + {file = "more-itertools-10.4.0.tar.gz", hash = "sha256:fe0e63c4ab068eac62410ab05cccca2dc71ec44ba8ef29916a0090df061cf923"}, + {file = "more_itertools-10.4.0-py3-none-any.whl", hash = "sha256:0f7d9f83a0a8dcfa8a2694a770590d98a67ea943e3d9f5298309a484758c4e27"}, ] [[package]] name = "mypy" -version = "1.11.0" +version = "1.11.1" description = "Optional static typing for Python" optional = false python-versions = ">=3.8" files = [ - {file = "mypy-1.11.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:a3824187c99b893f90c845bab405a585d1ced4ff55421fdf5c84cb7710995229"}, - {file = "mypy-1.11.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:96f8dbc2c85046c81bcddc246232d500ad729cb720da4e20fce3b542cab91287"}, - {file = "mypy-1.11.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1a5d8d8dd8613a3e2be3eae829ee891b6b2de6302f24766ff06cb2875f5be9c6"}, - {file = "mypy-1.11.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:72596a79bbfb195fd41405cffa18210af3811beb91ff946dbcb7368240eed6be"}, - {file = "mypy-1.11.0-cp310-cp310-win_amd64.whl", hash = "sha256:35ce88b8ed3a759634cb4eb646d002c4cef0a38f20565ee82b5023558eb90c00"}, - {file = "mypy-1.11.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:98790025861cb2c3db8c2f5ad10fc8c336ed2a55f4daf1b8b3f877826b6ff2eb"}, - {file = "mypy-1.11.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:25bcfa75b9b5a5f8d67147a54ea97ed63a653995a82798221cca2a315c0238c1"}, - {file = "mypy-1.11.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bea2a0e71c2a375c9fa0ede3d98324214d67b3cbbfcbd55ac8f750f85a414e3"}, - {file = "mypy-1.11.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:d2b3d36baac48e40e3064d2901f2fbd2a2d6880ec6ce6358825c85031d7c0d4d"}, - {file = "mypy-1.11.0-cp311-cp311-win_amd64.whl", hash = "sha256:d8e2e43977f0e09f149ea69fd0556623919f816764e26d74da0c8a7b48f3e18a"}, - {file = "mypy-1.11.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:1d44c1e44a8be986b54b09f15f2c1a66368eb43861b4e82573026e04c48a9e20"}, - {file = "mypy-1.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:cea3d0fb69637944dd321f41bc896e11d0fb0b0aa531d887a6da70f6e7473aba"}, - {file = "mypy-1.11.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a83ec98ae12d51c252be61521aa5731f5512231d0b738b4cb2498344f0b840cd"}, - {file = "mypy-1.11.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:c7b73a856522417beb78e0fb6d33ef89474e7a622db2653bc1285af36e2e3e3d"}, - {file = "mypy-1.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:f2268d9fcd9686b61ab64f077be7ffbc6fbcdfb4103e5dd0cc5eaab53a8886c2"}, - {file = "mypy-1.11.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:940bfff7283c267ae6522ef926a7887305945f716a7704d3344d6d07f02df850"}, - {file = "mypy-1.11.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:14f9294528b5f5cf96c721f231c9f5b2733164e02c1c018ed1a0eff8a18005ac"}, - {file = "mypy-1.11.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d7b54c27783991399046837df5c7c9d325d921394757d09dbcbf96aee4649fe9"}, - {file = "mypy-1.11.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:65f190a6349dec29c8d1a1cd4aa71284177aee5949e0502e6379b42873eddbe7"}, - {file = "mypy-1.11.0-cp38-cp38-win_amd64.whl", hash = "sha256:dbe286303241fea8c2ea5466f6e0e6a046a135a7e7609167b07fd4e7baf151bf"}, - {file = "mypy-1.11.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:104e9c1620c2675420abd1f6c44bab7dd33cc85aea751c985006e83dcd001095"}, - {file = "mypy-1.11.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f006e955718ecd8d159cee9932b64fba8f86ee6f7728ca3ac66c3a54b0062abe"}, - {file = "mypy-1.11.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:becc9111ca572b04e7e77131bc708480cc88a911adf3d0239f974c034b78085c"}, - {file = "mypy-1.11.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:6801319fe76c3f3a3833f2b5af7bd2c17bb93c00026a2a1b924e6762f5b19e13"}, - {file = "mypy-1.11.0-cp39-cp39-win_amd64.whl", hash = "sha256:c1a184c64521dc549324ec6ef7cbaa6b351912be9cb5edb803c2808a0d7e85ac"}, - {file = "mypy-1.11.0-py3-none-any.whl", hash = "sha256:56913ec8c7638b0091ef4da6fcc9136896914a9d60d54670a75880c3e5b99ace"}, - {file = "mypy-1.11.0.tar.gz", hash = "sha256:93743608c7348772fdc717af4aeee1997293a1ad04bc0ea6efa15bf65385c538"}, + {file = "mypy-1.11.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:a32fc80b63de4b5b3e65f4be82b4cfa362a46702672aa6a0f443b4689af7008c"}, + {file = "mypy-1.11.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c1952f5ea8a5a959b05ed5f16452fddadbaae48b5d39235ab4c3fc444d5fd411"}, + {file = "mypy-1.11.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e1e30dc3bfa4e157e53c1d17a0dad20f89dc433393e7702b813c10e200843b03"}, + {file = "mypy-1.11.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:2c63350af88f43a66d3dfeeeb8d77af34a4f07d760b9eb3a8697f0386c7590b4"}, + {file = "mypy-1.11.1-cp310-cp310-win_amd64.whl", hash = "sha256:a831671bad47186603872a3abc19634f3011d7f83b083762c942442d51c58d58"}, + {file = "mypy-1.11.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:7b6343d338390bb946d449677726edf60102a1c96079b4f002dedff375953fc5"}, + {file = "mypy-1.11.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e4fe9f4e5e521b458d8feb52547f4bade7ef8c93238dfb5bbc790d9ff2d770ca"}, + {file = "mypy-1.11.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:886c9dbecc87b9516eff294541bf7f3655722bf22bb898ee06985cd7269898de"}, + {file = "mypy-1.11.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:fca4a60e1dd9fd0193ae0067eaeeb962f2d79e0d9f0f66223a0682f26ffcc809"}, + {file = "mypy-1.11.1-cp311-cp311-win_amd64.whl", hash = "sha256:0bd53faf56de9643336aeea1c925012837432b5faf1701ccca7fde70166ccf72"}, + {file = "mypy-1.11.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:f39918a50f74dc5969807dcfaecafa804fa7f90c9d60506835036cc1bc891dc8"}, + {file = "mypy-1.11.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0bc71d1fb27a428139dd78621953effe0d208aed9857cb08d002280b0422003a"}, + {file = "mypy-1.11.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b868d3bcff720dd7217c383474008ddabaf048fad8d78ed948bb4b624870a417"}, + {file = "mypy-1.11.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a707ec1527ffcdd1c784d0924bf5cb15cd7f22683b919668a04d2b9c34549d2e"}, + {file = "mypy-1.11.1-cp312-cp312-win_amd64.whl", hash = "sha256:64f4a90e3ea07f590c5bcf9029035cf0efeae5ba8be511a8caada1a4893f5525"}, + {file = "mypy-1.11.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:749fd3213916f1751fff995fccf20c6195cae941dc968f3aaadf9bb4e430e5a2"}, + {file = "mypy-1.11.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:b639dce63a0b19085213ec5fdd8cffd1d81988f47a2dec7100e93564f3e8fb3b"}, + {file = "mypy-1.11.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c956b49c5d865394d62941b109728c5c596a415e9c5b2be663dd26a1ff07bc0"}, + {file = "mypy-1.11.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:45df906e8b6804ef4b666af29a87ad9f5921aad091c79cc38e12198e220beabd"}, + {file = "mypy-1.11.1-cp38-cp38-win_amd64.whl", hash = "sha256:d44be7551689d9d47b7abc27c71257adfdb53f03880841a5db15ddb22dc63edb"}, + {file = "mypy-1.11.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:2684d3f693073ab89d76da8e3921883019ea8a3ec20fa5d8ecca6a2db4c54bbe"}, + {file = "mypy-1.11.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:79c07eb282cb457473add5052b63925e5cc97dfab9812ee65a7c7ab5e3cb551c"}, + {file = "mypy-1.11.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11965c2f571ded6239977b14deebd3f4c3abd9a92398712d6da3a772974fad69"}, + {file = "mypy-1.11.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a2b43895a0f8154df6519706d9bca8280cda52d3d9d1514b2d9c3e26792a0b74"}, + {file = "mypy-1.11.1-cp39-cp39-win_amd64.whl", hash = "sha256:1a81cf05975fd61aec5ae16501a091cfb9f605dc3e3c878c0da32f250b74760b"}, + {file = "mypy-1.11.1-py3-none-any.whl", hash = "sha256:0624bdb940255d2dd24e829d99a13cfeb72e4e9031f9492148f410ed30bcab54"}, + {file = "mypy-1.11.1.tar.gz", hash = "sha256:f404a0b069709f18bbdb702eb3dcfe51910602995de00bd39cea3050b5772d08"}, ] [package.dependencies] @@ -1478,24 +1511,53 @@ testing = ["google-api-core (>=1.31.5)"] [[package]] name = "protobuf" -version = "5.27.2" +version = "5.27.3" description = "" optional = false python-versions = ">=3.8" files = [ - {file = "protobuf-5.27.2-cp310-abi3-win32.whl", hash = "sha256:354d84fac2b0d76062e9b3221f4abbbacdfd2a4d8af36bab0474f3a0bb30ab38"}, - {file = "protobuf-5.27.2-cp310-abi3-win_amd64.whl", hash = "sha256:0e341109c609749d501986b835f667c6e1e24531096cff9d34ae411595e26505"}, - {file = "protobuf-5.27.2-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:a109916aaac42bff84702fb5187f3edadbc7c97fc2c99c5ff81dd15dcce0d1e5"}, - {file = "protobuf-5.27.2-cp38-abi3-manylinux2014_aarch64.whl", hash = "sha256:176c12b1f1c880bf7a76d9f7c75822b6a2bc3db2d28baa4d300e8ce4cde7409b"}, - {file = "protobuf-5.27.2-cp38-abi3-manylinux2014_x86_64.whl", hash = "sha256:b848dbe1d57ed7c191dfc4ea64b8b004a3f9ece4bf4d0d80a367b76df20bf36e"}, - {file = "protobuf-5.27.2-cp38-cp38-win32.whl", hash = "sha256:4fadd8d83e1992eed0248bc50a4a6361dc31bcccc84388c54c86e530b7f58863"}, - {file = "protobuf-5.27.2-cp38-cp38-win_amd64.whl", hash = "sha256:610e700f02469c4a997e58e328cac6f305f649826853813177e6290416e846c6"}, - {file = "protobuf-5.27.2-cp39-cp39-win32.whl", hash = "sha256:9e8f199bf7f97bd7ecebffcae45ebf9527603549b2b562df0fbc6d4d688f14ca"}, - {file = "protobuf-5.27.2-cp39-cp39-win_amd64.whl", hash = "sha256:7fc3add9e6003e026da5fc9e59b131b8f22b428b991ccd53e2af8071687b4fce"}, - {file = "protobuf-5.27.2-py3-none-any.whl", hash = "sha256:54330f07e4949d09614707c48b06d1a22f8ffb5763c159efd5c0928326a91470"}, - {file = "protobuf-5.27.2.tar.gz", hash = "sha256:f3ecdef226b9af856075f28227ff2c90ce3a594d092c39bee5513573f25e2714"}, + {file = "protobuf-5.27.3-cp310-abi3-win32.whl", hash = "sha256:dcb307cd4ef8fec0cf52cb9105a03d06fbb5275ce6d84a6ae33bc6cf84e0a07b"}, + {file = "protobuf-5.27.3-cp310-abi3-win_amd64.whl", hash = "sha256:16ddf3f8c6c41e1e803da7abea17b1793a97ef079a912e42351eabb19b2cffe7"}, + {file = "protobuf-5.27.3-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:68248c60d53f6168f565a8c76dc58ba4fa2ade31c2d1ebdae6d80f969cdc2d4f"}, + {file = "protobuf-5.27.3-cp38-abi3-manylinux2014_aarch64.whl", hash = "sha256:b8a994fb3d1c11156e7d1e427186662b64694a62b55936b2b9348f0a7c6625ce"}, + {file = "protobuf-5.27.3-cp38-abi3-manylinux2014_x86_64.whl", hash = "sha256:a55c48f2a2092d8e213bd143474df33a6ae751b781dd1d1f4d953c128a415b25"}, + {file = "protobuf-5.27.3-cp38-cp38-win32.whl", hash = "sha256:043853dcb55cc262bf2e116215ad43fa0859caab79bb0b2d31b708f128ece035"}, + {file = "protobuf-5.27.3-cp38-cp38-win_amd64.whl", hash = "sha256:c2a105c24f08b1e53d6c7ffe69cb09d0031512f0b72f812dd4005b8112dbe91e"}, + {file = "protobuf-5.27.3-cp39-cp39-win32.whl", hash = "sha256:c84eee2c71ed83704f1afbf1a85c3171eab0fd1ade3b399b3fad0884cbcca8bf"}, + {file = "protobuf-5.27.3-cp39-cp39-win_amd64.whl", hash = "sha256:af7c0b7cfbbb649ad26132e53faa348580f844d9ca46fd3ec7ca48a1ea5db8a1"}, + {file = "protobuf-5.27.3-py3-none-any.whl", hash = "sha256:8572c6533e544ebf6899c360e91d6bcbbee2549251643d32c52cf8a5de295ba5"}, + {file = "protobuf-5.27.3.tar.gz", hash = "sha256:82460903e640f2b7e34ee81a947fdaad89de796d324bcbc38ff5430bcdead82c"}, +] + +[[package]] +name = "psutil" +version = "6.0.0" +description = "Cross-platform lib for process and system monitoring in Python." +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,>=2.7" +files = [ + {file = "psutil-6.0.0-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:a021da3e881cd935e64a3d0a20983bda0bb4cf80e4f74fa9bfcb1bc5785360c6"}, + {file = "psutil-6.0.0-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:1287c2b95f1c0a364d23bc6f2ea2365a8d4d9b726a3be7294296ff7ba97c17f0"}, + {file = "psutil-6.0.0-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:a9a3dbfb4de4f18174528d87cc352d1f788b7496991cca33c6996f40c9e3c92c"}, + {file = "psutil-6.0.0-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:6ec7588fb3ddaec7344a825afe298db83fe01bfaaab39155fa84cf1c0d6b13c3"}, + {file = "psutil-6.0.0-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:1e7c870afcb7d91fdea2b37c24aeb08f98b6d67257a5cb0a8bc3ac68d0f1a68c"}, + {file = "psutil-6.0.0-cp27-none-win32.whl", hash = "sha256:02b69001f44cc73c1c5279d02b30a817e339ceb258ad75997325e0e6169d8b35"}, + {file = "psutil-6.0.0-cp27-none-win_amd64.whl", hash = "sha256:21f1fb635deccd510f69f485b87433460a603919b45e2a324ad65b0cc74f8fb1"}, + {file = "psutil-6.0.0-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:c588a7e9b1173b6e866756dde596fd4cad94f9399daf99ad8c3258b3cb2b47a0"}, + {file = "psutil-6.0.0-cp36-abi3-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6ed2440ada7ef7d0d608f20ad89a04ec47d2d3ab7190896cd62ca5fc4fe08bf0"}, + {file = "psutil-6.0.0-cp36-abi3-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5fd9a97c8e94059b0ef54a7d4baf13b405011176c3b6ff257c247cae0d560ecd"}, + {file = "psutil-6.0.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e2e8d0054fc88153ca0544f5c4d554d42e33df2e009c4ff42284ac9ebdef4132"}, + {file = "psutil-6.0.0-cp36-cp36m-win32.whl", hash = "sha256:fc8c9510cde0146432bbdb433322861ee8c3efbf8589865c8bf8d21cb30c4d14"}, + {file = "psutil-6.0.0-cp36-cp36m-win_amd64.whl", hash = "sha256:34859b8d8f423b86e4385ff3665d3f4d94be3cdf48221fbe476e883514fdb71c"}, + {file = "psutil-6.0.0-cp37-abi3-win32.whl", hash = "sha256:a495580d6bae27291324fe60cea0b5a7c23fa36a7cd35035a16d93bdcf076b9d"}, + {file = "psutil-6.0.0-cp37-abi3-win_amd64.whl", hash = "sha256:33ea5e1c975250a720b3a6609c490db40dae5d83a4eb315170c4fe0d8b1f34b3"}, + {file = "psutil-6.0.0-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:ffe7fc9b6b36beadc8c322f84e1caff51e8703b88eee1da46d1e3a6ae11b4fd0"}, + {file = "psutil-6.0.0.tar.gz", hash = "sha256:8faae4f310b6d969fa26ca0545338b21f73c6b15db7c4a8d934a5482faa818f2"}, ] +[package.extras] +test = ["enum34", "ipaddress", "mock", "pywin32", "wmi"] + [[package]] name = "psycopg2-binary" version = "2.9.9" @@ -1765,17 +1827,17 @@ files = [ [[package]] name = "redis" -version = "5.0.7" +version = "5.0.8" description = "Python client for Redis database and key-value store" optional = false python-versions = ">=3.7" files = [ - {file = "redis-5.0.7-py3-none-any.whl", hash = "sha256:0e479e24da960c690be5d9b96d21f7b918a98c0cf49af3b6fafaa0753f93a0db"}, - {file = "redis-5.0.7.tar.gz", hash = "sha256:8f611490b93c8109b50adc317b31bfd84fff31def3475b92e7e80bf39f48175b"}, + {file = "redis-5.0.8-py3-none-any.whl", hash = "sha256:56134ee08ea909106090934adc36f65c9bcbbaecea5b21ba704ba6fb561f8eb4"}, + {file = "redis-5.0.8.tar.gz", hash = "sha256:0c5b10d387568dfe0698c6fad6615750c24170e548ca2deac10c649d463e9870"}, ] [package.extras] -hiredis = ["hiredis (>=1.0.0)"] +hiredis = ["hiredis (>1.0.0)"] ocsp = ["cryptography (>=36.0.1)", "pyopenssl (==20.0.1)", "requests (>=2.26.0)"] [[package]] @@ -1862,13 +1924,13 @@ crt = ["botocore[crt] (>=1.33.2,<2.0a.0)"] [[package]] name = "sentry-sdk" -version = "2.11.0" +version = "2.12.0" description = "Python client for Sentry (https://sentry.io)" optional = false python-versions = ">=3.6" files = [ - {file = "sentry_sdk-2.11.0-py2.py3-none-any.whl", hash = "sha256:d964710e2dbe015d9dc4ff0ad16225d68c3b36936b742a6fe0504565b760a3b7"}, - {file = "sentry_sdk-2.11.0.tar.gz", hash = "sha256:4ca16e9f5c7c6bc2fb2d5c956219f4926b148e511fffdbbde711dc94f1e0468f"}, + {file = "sentry_sdk-2.12.0-py2.py3-none-any.whl", hash = "sha256:7a8d5163d2ba5c5f4464628c6b68f85e86972f7c636acc78aed45c61b98b7a5e"}, + {file = "sentry_sdk-2.12.0.tar.gz", hash = "sha256:8763840497b817d44c49b3fe3f5f7388d083f2337ffedf008b2cdb63b5c86dc6"}, ] [package.dependencies] @@ -1898,7 +1960,7 @@ langchain = ["langchain (>=0.0.210)"] loguru = ["loguru (>=0.5)"] openai = ["openai (>=1.0.0)", "tiktoken (>=0.3.0)"] opentelemetry = ["opentelemetry-distro (>=0.35b0)"] -opentelemetry-experimental = ["opentelemetry-instrumentation-aio-pika (==0.46b0)", "opentelemetry-instrumentation-aiohttp-client (==0.46b0)", "opentelemetry-instrumentation-aiopg (==0.46b0)", "opentelemetry-instrumentation-asgi (==0.46b0)", "opentelemetry-instrumentation-asyncio (==0.46b0)", "opentelemetry-instrumentation-asyncpg (==0.46b0)", "opentelemetry-instrumentation-aws-lambda (==0.46b0)", "opentelemetry-instrumentation-boto (==0.46b0)", "opentelemetry-instrumentation-boto3sqs (==0.46b0)", "opentelemetry-instrumentation-botocore (==0.46b0)", "opentelemetry-instrumentation-cassandra (==0.46b0)", "opentelemetry-instrumentation-celery (==0.46b0)", "opentelemetry-instrumentation-confluent-kafka (==0.46b0)", "opentelemetry-instrumentation-dbapi (==0.46b0)", "opentelemetry-instrumentation-django (==0.46b0)", "opentelemetry-instrumentation-elasticsearch (==0.46b0)", "opentelemetry-instrumentation-falcon (==0.46b0)", "opentelemetry-instrumentation-fastapi (==0.46b0)", "opentelemetry-instrumentation-flask (==0.46b0)", "opentelemetry-instrumentation-grpc (==0.46b0)", "opentelemetry-instrumentation-httpx (==0.46b0)", "opentelemetry-instrumentation-jinja2 (==0.46b0)", "opentelemetry-instrumentation-kafka-python (==0.46b0)", "opentelemetry-instrumentation-logging (==0.46b0)", "opentelemetry-instrumentation-mysql (==0.46b0)", "opentelemetry-instrumentation-mysqlclient (==0.46b0)", "opentelemetry-instrumentation-pika (==0.46b0)", "opentelemetry-instrumentation-psycopg (==0.46b0)", "opentelemetry-instrumentation-psycopg2 (==0.46b0)", "opentelemetry-instrumentation-pymemcache (==0.46b0)", "opentelemetry-instrumentation-pymongo (==0.46b0)", "opentelemetry-instrumentation-pymysql (==0.46b0)", "opentelemetry-instrumentation-pyramid (==0.46b0)", "opentelemetry-instrumentation-redis (==0.46b0)", "opentelemetry-instrumentation-remoulade (==0.46b0)", "opentelemetry-instrumentation-requests (==0.46b0)", "opentelemetry-instrumentation-sklearn (==0.46b0)", "opentelemetry-instrumentation-sqlalchemy (==0.46b0)", "opentelemetry-instrumentation-sqlite3 (==0.46b0)", "opentelemetry-instrumentation-starlette (==0.46b0)", "opentelemetry-instrumentation-system-metrics (==0.46b0)", "opentelemetry-instrumentation-threading (==0.46b0)", "opentelemetry-instrumentation-tornado (==0.46b0)", "opentelemetry-instrumentation-tortoiseorm (==0.46b0)", "opentelemetry-instrumentation-urllib (==0.46b0)", "opentelemetry-instrumentation-urllib3 (==0.46b0)", "opentelemetry-instrumentation-wsgi (==0.46b0)"] +opentelemetry-experimental = ["opentelemetry-distro"] pure-eval = ["asttokens", "executing", "pure-eval"] pymongo = ["pymongo (>=3.1)"] pyspark = ["pyspark (>=2.4.4)"] @@ -1957,13 +2019,13 @@ tests = ["cython", "littleutils", "pygments", "pytest", "typeguard"] [[package]] name = "strawberry-graphql" -version = "0.237.2" +version = "0.237.3" description = "A library for creating GraphQL APIs" optional = false python-versions = "<4.0,>=3.8" files = [ - {file = "strawberry_graphql-0.237.2-py3-none-any.whl", hash = "sha256:0cab6ceb13d5d598faad7afc721a39cabae79e9bc801c2aac0b5bcd3402e4e10"}, - {file = "strawberry_graphql-0.237.2.tar.gz", hash = "sha256:ac419798303195547e3b286ee810ce957072d5aaac55e5f639cdc4bbb0d72c46"}, + {file = "strawberry_graphql-0.237.3-py3-none-any.whl", hash = "sha256:2dc43a5036995edf614d21c9faca8b6848a09353d167ae05f42a9d0e976e1611"}, + {file = "strawberry_graphql-0.237.3.tar.gz", hash = "sha256:8e04bb8821b303cb2cbd2c981f211b2d61bd21e3d171a638d93ceb99081194ac"}, ] [package.dependencies] @@ -2038,13 +2100,13 @@ files = [ [[package]] name = "types-pyyaml" -version = "6.0.12.20240724" +version = "6.0.12.20240808" description = "Typing stubs for PyYAML" optional = false python-versions = ">=3.8" files = [ - {file = "types-PyYAML-6.0.12.20240724.tar.gz", hash = "sha256:cf7b31ae67e0c5b2919c703d2affc415485099d3fe6666a6912f040fd05cb67f"}, - {file = "types_PyYAML-6.0.12.20240724-py3-none-any.whl", hash = "sha256:e5becec598f3aa3a2ddf671de4a75fa1c6856fbf73b2840286c9d50fae2d5d48"}, + {file = "types-PyYAML-6.0.12.20240808.tar.gz", hash = "sha256:b8f76ddbd7f65440a8bda5526a9607e4c7a322dc2f8e1a8c405644f9a6f4b9af"}, + {file = "types_PyYAML-6.0.12.20240808-py3-none-any.whl", hash = "sha256:deda34c5c655265fc517b546c902aa6eed2ef8d3e921e4765fe606fe2afe8d35"}, ] [[package]] @@ -2185,4 +2247,4 @@ files = [ [metadata] lock-version = "2.0" python-versions = "^3.12" -content-hash = "1bc879f859bddd5739c8b32bf8b200c8f1df058af434509bdf3e1abc9c029a6a" +content-hash = "bad68d4716a3219d3af40a221f3f1fd1dee0979243c452d192c754a212795725" diff --git a/pyproject.toml b/pyproject.toml index 09630c4..e7d4e47 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,6 +31,8 @@ django-reversion = "*" uwsgi = "*" aws-sns-message-validator = "*" google-api-python-client = "*" +django-health-check = "*" +psutil = "*" # Required by django-health-check [tool.poetry.dev-dependencies] dacite = "*" From b65f5934758a19614fafa2f601f7648caf091493 Mon Sep 17 00:00:00 2001 From: thenav56 Date: Fri, 9 Aug 2024 14:14:56 +0545 Subject: [PATCH 18/51] Proper DOING clone --- .../management/commands/process_not_done_time_entries.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/track/management/commands/process_not_done_time_entries.py b/apps/track/management/commands/process_not_done_time_entries.py index 9bf22c8..84023e8 100644 --- a/apps/track/management/commands/process_not_done_time_entries.py +++ b/apps/track/management/commands/process_not_done_time_entries.py @@ -33,9 +33,14 @@ def clone_doing_entries(self, today: datetime.date): if existing_qs.exists(): continue time_entry.pk = None # Create a new copy - time_entry.status = TimeEntry.Status.TODO # Use todo Status time_entry.date = today + time_entry.status = TimeEntry.Status.TODO # Use todo Status + # Clear data + time_entry.start_time = None + time_entry.duration = None + # Save time_entry.save() + cloned_count += 1 self.stdout.write(self.style.SUCCESS(f"{cloned_count} DOING cloned")) def handle(self, **_): From 12934974c2eb41c0bff44b8b55d8584e46617faf Mon Sep 17 00:00:00 2001 From: thenav56 Date: Tue, 13 Aug 2024 07:04:14 +0545 Subject: [PATCH 19/51] Fix process_not_done_time_entries timezone issue --- .../management/commands/process_not_done_time_entries.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/track/management/commands/process_not_done_time_entries.py b/apps/track/management/commands/process_not_done_time_entries.py index 84023e8..c251363 100644 --- a/apps/track/management/commands/process_not_done_time_entries.py +++ b/apps/track/management/commands/process_not_done_time_entries.py @@ -44,6 +44,9 @@ def clone_doing_entries(self, today: datetime.date): self.stdout.write(self.style.SUCCESS(f"{cloned_count} DOING cloned")) def handle(self, **_): - today = timezone.now().date() + # XXX: Use the system localtime to figure out the today's date + # NOTE: timezone.now() will provide datetime with UTC + # Which can give wrong date compare to local timezone + today = timezone.localtime(timezone.now()).date() self.move_todo_entries(today) self.clone_doing_entries(today) From 17b8e0525dc24de2de417fd294bfa95f936b7e2d Mon Sep 17 00:00:00 2001 From: thenav56 Date: Tue, 13 Aug 2024 20:56:33 +0545 Subject: [PATCH 20/51] Add PROJECT_MANAGEMENT time entry type - Add project title in contract __str__ --- apps/track/admin.py | 5 +++- .../migrations/0011_alter_timeentry_type.py | 29 +++++++++++++++++++ apps/track/models.py | 8 +++-- schema.graphql | 1 + 4 files changed, 39 insertions(+), 4 deletions(-) create mode 100644 apps/track/migrations/0011_alter_timeentry_type.py diff --git a/apps/track/admin.py b/apps/track/admin.py index 3f7a45b..54c51c4 100644 --- a/apps/track/admin.py +++ b/apps/track/admin.py @@ -15,7 +15,10 @@ class ContractTaskInline(UserResourceTabularInline): @admin.register(Contract) class ContractAdmin(VersionAdmin, UserResourceAdmin): - search_fields = ("name",) + search_fields = ( + "project__name", + "name", + ) list_filter = ( AutocompleteFilterFactory("Project", "project"), AutocompleteFilterFactory("Created By", "created_by"), diff --git a/apps/track/migrations/0011_alter_timeentry_type.py b/apps/track/migrations/0011_alter_timeentry_type.py new file mode 100644 index 0000000..e4ac09f --- /dev/null +++ b/apps/track/migrations/0011_alter_timeentry_type.py @@ -0,0 +1,29 @@ +# Generated by Django 4.2.15 on 2024-08-13 15:12 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("track", "0010_timeentry_created_at"), + ] + + operations = [ + migrations.AlterField( + model_name="timeentry", + name="type", + field=models.PositiveSmallIntegerField( + choices=[ + (1000, "Design"), + (1100, "Development"), + (1200, "DevOps"), + (2000, "Documentation"), + (3000, "Internal Discussion"), + (4000, "Meeting"), + (5000, "Project Management"), + (6000, "QA"), + ] + ), + ), + ] diff --git a/apps/track/models.py b/apps/track/models.py index 658b67d..b3cb0cc 100644 --- a/apps/track/models.py +++ b/apps/track/models.py @@ -16,7 +16,8 @@ class Contract(UserResource): tasks: models.QuerySet["Task"] def __str__(self): - return f"{self.name} ({self.total_estimated_hours} hours)" + # NOTE: N+1 + return f"{self.project.name} -> {self.name} ({self.total_estimated_hours} hours)" class Task(UserResource): @@ -38,9 +39,10 @@ class Type(models.IntegerChoices): DEVELOPMENT = 1100, _("Development") DEV_OPS = 1200, _("DevOps") DOCUMENTATION = 2000, _("Documentation") - INTERNAL_DISCUSSION = 3000, _("Documentation") + INTERNAL_DISCUSSION = 3000, _("Internal Discussion") MEETING = 4000, _("Meeting") - QUALITY_ASSURANCE = 5000, _("QA") + PROJECT_MANAGEMENT = 5000, _("Project Management") + QUALITY_ASSURANCE = 6000, _("QA") class Status(models.IntegerChoices): DOING = 1, _("Doing") diff --git a/schema.graphql b/schema.graphql index f9a040f..3f5618f 100644 --- a/schema.graphql +++ b/schema.graphql @@ -551,6 +551,7 @@ enum TimeEntryTypeEnum { DOCUMENTATION INTERNAL_DISCUSSION MEETING + PROJECT_MANAGEMENT QUALITY_ASSURANCE } From ce5c9f6e53e4dbb417caeb057e0df610a2a4043b Mon Sep 17 00:00:00 2001 From: thenav56 Date: Tue, 13 Aug 2024 21:26:17 +0545 Subject: [PATCH 21/51] Store duration as minutes --- .../0012_alter_timeentry_duration.py | 23 +++++++++++++++++++ apps/track/models.py | 6 ++++- apps/track/serializers.py | 4 +++- apps/track/tests/test_mutations.py | 18 +++++++-------- apps/track/tests/test_queries.py | 4 ++-- schema.graphql | 3 +++ utils/strawberry/serializers.py | 8 +++++++ utils/strawberry/transformers.py | 5 ++-- utils/strawberry/types.py | 11 +++++---- 9 files changed, 63 insertions(+), 19 deletions(-) create mode 100644 apps/track/migrations/0012_alter_timeentry_duration.py diff --git a/apps/track/migrations/0012_alter_timeentry_duration.py b/apps/track/migrations/0012_alter_timeentry_duration.py new file mode 100644 index 0000000..fbb1ffa --- /dev/null +++ b/apps/track/migrations/0012_alter_timeentry_duration.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2.15 on 2024-08-13 15:31 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("track", "0011_alter_timeentry_type"), + ] + + operations = [ + migrations.RemoveField( + model_name="timeentry", + name="duration", + ), + # NOTE: At this state, we didn't have much data + migrations.AddField( + model_name="timeentry", + name="duration", + field=models.PositiveSmallIntegerField(blank=True, help_text="Minutes", null=True), + ), + ] diff --git a/apps/track/models.py b/apps/track/models.py index b3cb0cc..988c501 100644 --- a/apps/track/models.py +++ b/apps/track/models.py @@ -64,7 +64,11 @@ class Status(models.IntegerChoices): start_time = models.TimeField(null=True, blank=True) description = models.TextField(blank=True) - duration = models.DurationField(null=True, blank=True) + duration = models.PositiveSmallIntegerField( + null=True, + blank=True, + help_text=_("Minutes"), + ) user_id: int task_id: int diff --git a/apps/track/serializers.py b/apps/track/serializers.py index 52a5973..d233901 100644 --- a/apps/track/serializers.py +++ b/apps/track/serializers.py @@ -1,12 +1,14 @@ from rest_framework import serializers from apps.common.serializers import TempClientIdMixin -from utils.strawberry.serializers import IntegerIDField +from utils.strawberry.serializers import IntegerIDField, TimeDurationField from .models import TimeEntry class TimeEntrySerializer(TempClientIdMixin, serializers.ModelSerializer): + duration = TimeDurationField(required=False, allow_null=True) + class Meta: # type: ignore[reportIncompatibleVariab] model = TimeEntry fields = ( diff --git a/apps/track/tests/test_mutations.py b/apps/track/tests/test_mutations.py index e03daf2..5fd00cb 100644 --- a/apps/track/tests/test_mutations.py +++ b/apps/track/tests/test_mutations.py @@ -80,7 +80,7 @@ def setUpClass(cls): type=TimeEntry.Type.DEVELOPMENT, description="Norm description", status=TimeEntry.Status.DOING, - duration="00:40", + duration=30, start_time="09:30:00", ) @@ -111,7 +111,7 @@ def test_bulk_time_entry_create(self): type=self.genum(TimeEntry.Type.DEVELOPMENT), description="Normal description", status=self.genum(TimeEntry.Status.DOING), - duration=30 * 60, + duration=30 / 60, startTime="09:30:00", clientId="client-id-01", ), @@ -148,7 +148,7 @@ def test_bulk_time_entry_update(self): type=self.genum(TimeEntry.Type.DESIGN), description="Normal description - 0", status=self.genum(TimeEntry.Status.DOING), - duration=30 * 60, + duration=30 / 60, startTime="09:31:00", clientId="client-id-01", ), @@ -159,7 +159,7 @@ def test_bulk_time_entry_update(self): type=self.genum(TimeEntry.Type.DEV_OPS), description="Normal description - 1", status=self.genum(TimeEntry.Status.DONE), - duration=30 * 60, + duration=30 / 60, startTime="09:32:00", clientId="client-id-02", ), @@ -179,7 +179,7 @@ def test_bulk_time_entry_update(self): "date": self.common_time_entry_kwargs["date"], "status": self.common_time_entry_kwargs["status"], "startTime": self.common_time_entry_kwargs["start_time"], - "duration": 40 * 60, # self.common_time_entry_kwargs["duration"] + "duration": 30 / 60, # self.common_time_entry_kwargs["duration"] "userId": self.gID(self.user.pk), "taskId": self.gID(self.active_tasks[0].pk), } @@ -264,7 +264,7 @@ def test_bulk_time_entry_mix(self): type=self.genum(TimeEntry.Type.DEVELOPMENT), description="Normal description - 0", status=self.genum(TimeEntry.Status.DOING), - duration=30 * 60, + duration=30 / 60, startTime="09:30:00", clientId="client-id-00", ), @@ -276,7 +276,7 @@ def test_bulk_time_entry_mix(self): type=self.genum(TimeEntry.Type.DESIGN), description="Normal description - 1", status=self.genum(TimeEntry.Status.DOING), - duration=30 * 60, + duration=30 / 60, startTime="09:31:00", clientId="client-id-01", ), @@ -287,7 +287,7 @@ def test_bulk_time_entry_mix(self): type=self.genum(TimeEntry.Type.DEV_OPS), description="Normal description - 2", status=self.genum(TimeEntry.Status.DOING), - duration=30 * 60, + duration=30 / 60, startTime="09:32:00", clientId="client-id-02", ), @@ -309,7 +309,7 @@ def test_bulk_time_entry_mix(self): "date": self.common_time_entry_kwargs["date"], "status": self.common_time_entry_kwargs["status"], "startTime": self.common_time_entry_kwargs["start_time"], - "duration": 40 * 60, # self.common_time_entry_kwargs["duration"] + "duration": 30 / 60, # self.common_time_entry_kwargs["duration"] "userId": self.gID(self.user.pk), "taskId": self.gID(self.active_tasks[0].pk), } diff --git a/apps/track/tests/test_queries.py b/apps/track/tests/test_queries.py index db95b48..1c65877 100644 --- a/apps/track/tests/test_queries.py +++ b/apps/track/tests/test_queries.py @@ -195,7 +195,7 @@ def test_my_time_entries(self): common_kwargs = dict( user=self.user, type=TimeEntry.Type.DEVELOPMENT, - duration="00:30", + duration=30, task=task, date=date, ) @@ -236,7 +236,7 @@ def test_my_time_entries(self): displayName=self.gID(self.user.display_name), ), status=self.genum(entry.status), - duration=30 * 60, + duration=30 / 60, description=None, startTime=None, ) diff --git a/schema.graphql b/schema.graphql index 3f5618f..c8c6951 100644 --- a/schema.graphql +++ b/schema.graphql @@ -463,6 +463,9 @@ type TaskTypeCountList { """Time (isoformat)""" scalar Time +""" +The `TimeDuration` scalar type represents Duration values in hours, The value is stored in minute in database +""" scalar TimeDuration input TimeEntryBulkCreateInput { diff --git a/utils/strawberry/serializers.py b/utils/strawberry/serializers.py index 6172938..443a03a 100644 --- a/utils/strawberry/serializers.py +++ b/utils/strawberry/serializers.py @@ -31,6 +31,14 @@ def run_validation(self, data=serializers.empty): return super().run_validation(data) +class TimeDurationField(serializers.FloatField): + """ + This field is created to override the graphene conversion of the floatfield -> TimeDurationField + """ + + pass + + serializers.ModelSerializer.serializer_field_mapping.update( { models.CharField: CustomCharField, diff --git a/utils/strawberry/transformers.py b/utils/strawberry/transformers.py index 3d7182a..2d17198 100644 --- a/utils/strawberry/transformers.py +++ b/utils/strawberry/transformers.py @@ -20,7 +20,7 @@ from . import types from .enums import get_enum_name_from_django_field -from .serializers import IntegerIDField, StringIDField +from .serializers import IntegerIDField, StringIDField, TimeDurationField """ XXX: @@ -68,7 +68,8 @@ def convert_serializer_field_to_generic_scalar(_): return types.GenericScalar -@get_strawberry_type_from_serializer_field.register(serializers.DurationField) # type: ignore[reportArgumentType] +# XXX: Custom field +@get_strawberry_type_from_serializer_field.register(TimeDurationField) # type: ignore[reportArgumentType] def convert_serializer_field_to_duration(_): return types.TimeDuration diff --git a/utils/strawberry/types.py b/utils/strawberry/types.py index 8669b96..baaf458 100644 --- a/utils/strawberry/types.py +++ b/utils/strawberry/types.py @@ -1,4 +1,3 @@ -import datetime import json import typing @@ -19,9 +18,13 @@ ) TimeDuration = strawberry.scalar( - typing.NewType("TimeDuration", int), - serialize=lambda v: v.seconds, - parse_value=lambda v: datetime.timedelta(seconds=v), + typing.NewType("TimeDuration", float), + description=( + "The `TimeDuration` scalar type represents Duration values in hours," " The value is stored in minute in database" + ), + # NOTE: v is in minutes + serialize=lambda v: v / 60, # From server + parse_value=lambda v: v * 60, # From client ) From 4d1fdb7c8d2643ad1d89a67ba31ab1232c3f78e6 Mon Sep 17 00:00:00 2001 From: thenav56 Date: Wed, 14 Aug 2024 18:03:13 +0545 Subject: [PATCH 22/51] Customize admin panel --- apps/common/admin.py | 5 + apps/common/templates/common/sign_in.html | 13 +- apps/common/views.py | 25 +++- apps/project/admin.py | 8 +- apps/static/css/admin-custom.css | 160 ++++++++++++++++++++++ apps/templates/admin/base_site.html | 28 ++++ apps/track/admin.py | 12 +- main/context_processors.py | 10 ++ main/settings.py | 8 +- main/urls.py | 5 + 10 files changed, 256 insertions(+), 18 deletions(-) create mode 100644 apps/static/css/admin-custom.css create mode 100644 apps/templates/admin/base_site.html create mode 100644 main/context_processors.py diff --git a/apps/common/admin.py b/apps/common/admin.py index 69fbdc3..5c1c23f 100644 --- a/apps/common/admin.py +++ b/apps/common/admin.py @@ -8,6 +8,11 @@ class VersionAdmin(OgVersionAdmin): history_latest_first = True +class PreventDeleteAdminMixin: + def has_delete_permission(self, request, obj=None): + return False + + class UserResourceAdmin(admin.ModelAdmin): def get_readonly_fields(self, *args, **kwargs): readonly_fields = super().get_readonly_fields(*args, **kwargs) # type: ignore[reportAttributeAccessIssue] diff --git a/apps/common/templates/common/sign_in.html b/apps/common/templates/common/sign_in.html index 77598fe..26009c4 100644 --- a/apps/common/templates/common/sign_in.html +++ b/apps/common/templates/common/sign_in.html @@ -7,7 +7,7 @@ +
+ {% if request.user.is_authenticated %}

Hi {{ request.user.display_name }} 🙂

Your email is {{ request.user.email }}

+
Go back to the application here: {{APP_FRONTEND_HOST}}
{% else %}

Hi there 🙂

Click below to sign in with Google

-

Client ID: {{ GOOGLE_OAUTH_CLIENT_ID }}

-

Redirect URI: {{ GOOGLE_OAUTH_REDIRECT_URL }}

+ {% if IS_DEBUG %} +

Client ID: {{ GOOGLE_OAUTH_CLIENT_ID }}

+

Redirect URI: {{ GOOGLE_OAUTH_REDIRECT_URL }}

+ {% endif %}
+

{{ APP_ENVIRONMENT }}

+
{% endif %}
diff --git a/apps/common/views.py b/apps/common/views.py index 78e9bdf..6b9c88f 100644 --- a/apps/common/views.py +++ b/apps/common/views.py @@ -29,19 +29,33 @@ def google_oauth(request): """ Google calls this URL after the user has signed in with their Google account. """ - token = request.POST["credential"] - redirect_to = request.GET.get("redirect_to") + error_postfix = ( + f"
Go back to the application here: {settings.APP_FRONTEND_HOST}" + ) + if request.method.upper() == "GET": + return HttpResponse( + f"Not sure what you are trying to do here {error_postfix}", + status=405, + ) + + token = request.POST.get("credential") + + if token is None: + return HttpResponse( + f"No credential provided {error_postfix}", + status=400, + ) try: user_data = id_token.verify_oauth2_token(token, requests.Request(), settings.GOOGLE_OAUTH_CLIENT_ID) if user_data["email_verified"] is not True: return HttpResponse( - "Email is not verified", + "Email is not verified {error_postfix}", status=400, ) except ValueError: return HttpResponse( - "Failed to process", + f"Failed to process {error_postfix}", status=403, ) @@ -61,7 +75,4 @@ def google_oauth(request): ) login(request, new_user) - if redirect_to: - return redirect(redirect_to) - return redirect(settings.APP_FRONTEND_HOST) diff --git a/apps/project/admin.py b/apps/project/admin.py index e7d8fcc..f2eb544 100644 --- a/apps/project/admin.py +++ b/apps/project/admin.py @@ -3,13 +3,13 @@ from django.db import models from django.http import HttpRequest -from apps.common.admin import UserResourceAdmin, VersionAdmin +from apps.common.admin import PreventDeleteAdminMixin, UserResourceAdmin, VersionAdmin from .models import Client, Contractor, Project @admin.register(Client) -class ClientAdmin(VersionAdmin, UserResourceAdmin): +class ClientAdmin(PreventDeleteAdminMixin, VersionAdmin, UserResourceAdmin): search_fields = ("name",) list_display = ("name", "created_by", "modified_by") @@ -18,7 +18,7 @@ def get_queryset(self, request: HttpRequest) -> models.QuerySet[Client]: @admin.register(Contractor) -class ContractorAdmin(VersionAdmin, UserResourceAdmin): +class ContractorAdmin(PreventDeleteAdminMixin, VersionAdmin, UserResourceAdmin): search_fields = ("name",) list_display = ("name", "created_by", "modified_by") @@ -28,7 +28,7 @@ def get_queryset(self, request: HttpRequest) -> models.QuerySet[Client]: @admin.register(Project) -class ProjectAdmin(VersionAdmin, UserResourceAdmin): +class ProjectAdmin(PreventDeleteAdminMixin, VersionAdmin, UserResourceAdmin): search_fields = ("name",) list_filter = ( AutocompleteFilterFactory("Client", "project_client"), diff --git a/apps/static/css/admin-custom.css b/apps/static/css/admin-custom.css new file mode 100644 index 0000000..2ed8431 --- /dev/null +++ b/apps/static/css/admin-custom.css @@ -0,0 +1,160 @@ +#branding h1, #branding h1 a:link, #branding h1 a:visited { + color: #fff; +} + +#header { + background: #685590; + color: #fff; +} +#header a:link, #header a:visited { + color: #fff; +} + +#header a:hover { + color: #fff; +} + +div.breadcrumbs { + background: #8e81c8; + color: #c4dce8; +} +div.breadcrumbs a.active { + color: #c4dce8; +!important; + font-weight: 300; +} + +div.breadcrumbs a:focus, div.breadcrumbs a:hover { + color: #c4dce8; +!important; + font-weight: 300; +} + +.select2-container--admin-autocomplete .select2-results__option--highlighted[aria-selected] { + background-color: #292D32; +} + +.paginator a:link, .paginator a:visited { + background: #292D32; +} + +.button, input[type=submit], input[type=button], .submit-row input, a.button { + background: #8e81c8; +} +.button:hover, input[type=submit]:hover, input[type=button]:hover { + background: #878787; +} + +.module h2, .module caption, .inline-group h2 { + background: #8e81c8; +} +#user-tools a:focus, #user-tools a:hover { + border: 0; + color: #ddd; +} + +.selector-chosen h2 { + background: #8e81c8 !important; +} + +.calendar td.selected a { + background: #8e81c8 !important; +} + +.calendar td a:focus, .timelist a:focus, +.calendar td a:hover, .timelist a:hover { + background: #8e81c8 !important; +} + +#changelist-filter li.selected a { + color: #444; +} + +fieldset.collapsed .collapse-toggle { + color: #444; +} + +a:link, a:visited { + color: #777; +} + +a:focus, a:hover { + color: #999; +} + +table thead th.sorted .sortoptions a.sortremove:focus:after, +table thead th.sorted .sortoptions a.sortremove:hover:after { + color: #444; +} + +a.active.selector-chooseall:focus, a.active.selector-clearall:focus, +a.active.selector-chooseall:hover, a.active.selector-clearall:hover { + color: #999; +} + +.calendar td a:active, .timelist a:active { + background: #444; +} + +.change-list ul.toplinks .date-back a:focus, +.change-list ul.toplinks .date-back a:hover { + color: #222; +} + +.paginator a.showall:focus, .paginator a.showall:hover { + color: #222; +} + +.paginator a:focus, .paginator a:hover { + background: #222; +} + +#changelist-filter a:focus, #changelist-filter a:hover, +#changelist-filter li.selected a:focus, +#changelist-filter li.selected a:hover { + color: #222; +} + +.calendar td a:active, .timelist a:active { + background: #292D32; + color: white; +} + +.calendar caption, .calendarbox h2 { + background: #999; +} + +.button.default, input[type=submit].default, .submit-row input.default { + background: #685590; +} + +.button.default:hover, input[type=submit].default:hover { + background: #191D22; +} + +.select2-container--admin-autocomplete .select2-results__option--highlighted[aria-selected] { + background-color: #292D32; +} + +.button.default, input[type=submit].default, .submit-row input.default { + background: #685590; +} + +.object-tools a:focus, .object-tools a:hover { + background-color: #292D32; +} + +input[type=file]::-webkit-file-upload-button:hover { + border: 0; + background: #292D32; +} + +#changelist-filter { + top: -250px; + right: 15px; +} + +#changelist-filter h2 { + padding-top: 0px; + padding-bottom: 0px +} diff --git a/apps/templates/admin/base_site.html b/apps/templates/admin/base_site.html new file mode 100644 index 0000000..57b92b7 --- /dev/null +++ b/apps/templates/admin/base_site.html @@ -0,0 +1,28 @@ +{% extends "admin/base_site.html" %} +{% load static %} + +{% block extrastyle %} + {{block.super}} + + +{% endblock %} diff --git a/apps/track/admin.py b/apps/track/admin.py index 54c51c4..296ab90 100644 --- a/apps/track/admin.py +++ b/apps/track/admin.py @@ -3,7 +3,12 @@ from django.db import models from django.http import HttpRequest -from apps.common.admin import UserResourceAdmin, UserResourceTabularInline, VersionAdmin +from apps.common.admin import ( + PreventDeleteAdminMixin, + UserResourceAdmin, + UserResourceTabularInline, + VersionAdmin, +) from .models import Contract, Task, TimeEntry @@ -11,10 +16,11 @@ class ContractTaskInline(UserResourceTabularInline): model = Task ordering = ("pk",) + can_delete = False @admin.register(Contract) -class ContractAdmin(VersionAdmin, UserResourceAdmin): +class ContractAdmin(PreventDeleteAdminMixin, VersionAdmin, UserResourceAdmin): search_fields = ( "project__name", "name", @@ -37,7 +43,7 @@ def get_project(self, obj): @admin.register(Task) -class TaskAdmin(VersionAdmin, UserResourceAdmin): +class TaskAdmin(PreventDeleteAdminMixin, VersionAdmin, UserResourceAdmin): search_fields = ("name",) list_filter = ( AutocompleteFilterFactory("Project", "contract__project"), diff --git a/main/context_processors.py b/main/context_processors.py new file mode 100644 index 0000000..033a29d --- /dev/null +++ b/main/context_processors.py @@ -0,0 +1,10 @@ +from django.conf import settings + + +def app_contexts(request): + return { + "request": request, + "APP_ENVIRONMENT": settings.APP_ENVIRONMENT, + "APP_FRONTEND_HOST": settings.APP_FRONTEND_HOST, + "IS_DEBUG": settings.DEBUG, + } diff --git a/main/settings.py b/main/settings.py index 7fea5a8..da32296 100644 --- a/main/settings.py +++ b/main/settings.py @@ -109,7 +109,7 @@ APP_DOMAIN = env("APP_DOMAIN") APP_FRONTEND_HOST = env("APP_FRONTEND_HOST") -APP_ENVIRONMENT = env("APP_ENVIRONMENT") +APP_ENVIRONMENT = env("APP_ENVIRONMENT").upper() APP_TYPE = env("APP_TYPE") # Application definition @@ -171,6 +171,7 @@ "django.template.context_processors.request", "django.contrib.auth.context_processors.auth", "django.contrib.messages.context_processors.messages", + "main.context_processors.app_contexts", ], }, }, @@ -230,6 +231,11 @@ # Static files (CSS, JavaScript, Images) # https://docs.djangoproject.com/en/4.2/howto/static-files/ +STATICFILES_DIRS = [ + os.path.join("apps", "static"), +] + + STATIC_URL = env("DJANGO_STATIC_URL") MEDIA_URL = env("DJANGO_MEDIA_URL") diff --git a/main/urls.py b/main/urls.py index bbf2ea6..dd7b9cf 100644 --- a/main/urls.py +++ b/main/urls.py @@ -7,6 +7,11 @@ from main.graphql.schema import CustomAsyncGraphQLView from main.graphql.schema import schema as graphql_schema +admin.site.site_header = "Timur" +admin.site.index_title = "Django Admin Panel" +admin.site.site_title = "HTML title from adminsitration" + + urlpatterns = [ path("admin/", admin.site.urls, name="admin"), path("health-check/", include("health_check.urls")), From 254c62a4a1bc6e98446288e3f0e62c27342aa3ec Mon Sep 17 00:00:00 2001 From: thenav56 Date: Wed, 14 Aug 2024 18:16:13 +0545 Subject: [PATCH 23/51] Change TimeDuration to minutes for both interface --- apps/track/serializers.py | 1 + apps/track/tests/test_mutations.py | 16 ++++++++-------- apps/track/tests/test_queries.py | 4 ++-- schema.graphql | 4 +--- utils/strawberry/serializers.py | 4 ++-- utils/strawberry/transformers.py | 13 +++++++------ utils/strawberry/types.py | 10 +++------- 7 files changed, 24 insertions(+), 28 deletions(-) diff --git a/apps/track/serializers.py b/apps/track/serializers.py index d233901..87e037e 100644 --- a/apps/track/serializers.py +++ b/apps/track/serializers.py @@ -7,6 +7,7 @@ class TimeEntrySerializer(TempClientIdMixin, serializers.ModelSerializer): + # Used just for adding description duration = TimeDurationField(required=False, allow_null=True) class Meta: # type: ignore[reportIncompatibleVariab] diff --git a/apps/track/tests/test_mutations.py b/apps/track/tests/test_mutations.py index 5fd00cb..9446407 100644 --- a/apps/track/tests/test_mutations.py +++ b/apps/track/tests/test_mutations.py @@ -111,7 +111,7 @@ def test_bulk_time_entry_create(self): type=self.genum(TimeEntry.Type.DEVELOPMENT), description="Normal description", status=self.genum(TimeEntry.Status.DOING), - duration=30 / 60, + duration=30, startTime="09:30:00", clientId="client-id-01", ), @@ -148,7 +148,7 @@ def test_bulk_time_entry_update(self): type=self.genum(TimeEntry.Type.DESIGN), description="Normal description - 0", status=self.genum(TimeEntry.Status.DOING), - duration=30 / 60, + duration=30, startTime="09:31:00", clientId="client-id-01", ), @@ -159,7 +159,7 @@ def test_bulk_time_entry_update(self): type=self.genum(TimeEntry.Type.DEV_OPS), description="Normal description - 1", status=self.genum(TimeEntry.Status.DONE), - duration=30 / 60, + duration=30, startTime="09:32:00", clientId="client-id-02", ), @@ -179,7 +179,7 @@ def test_bulk_time_entry_update(self): "date": self.common_time_entry_kwargs["date"], "status": self.common_time_entry_kwargs["status"], "startTime": self.common_time_entry_kwargs["start_time"], - "duration": 30 / 60, # self.common_time_entry_kwargs["duration"] + "duration": 30, # self.common_time_entry_kwargs["duration"] "userId": self.gID(self.user.pk), "taskId": self.gID(self.active_tasks[0].pk), } @@ -264,7 +264,7 @@ def test_bulk_time_entry_mix(self): type=self.genum(TimeEntry.Type.DEVELOPMENT), description="Normal description - 0", status=self.genum(TimeEntry.Status.DOING), - duration=30 / 60, + duration=25, startTime="09:30:00", clientId="client-id-00", ), @@ -276,7 +276,7 @@ def test_bulk_time_entry_mix(self): type=self.genum(TimeEntry.Type.DESIGN), description="Normal description - 1", status=self.genum(TimeEntry.Status.DOING), - duration=30 / 60, + duration=25, startTime="09:31:00", clientId="client-id-01", ), @@ -287,7 +287,7 @@ def test_bulk_time_entry_mix(self): type=self.genum(TimeEntry.Type.DEV_OPS), description="Normal description - 2", status=self.genum(TimeEntry.Status.DOING), - duration=30 / 60, + duration=25, startTime="09:32:00", clientId="client-id-02", ), @@ -309,7 +309,7 @@ def test_bulk_time_entry_mix(self): "date": self.common_time_entry_kwargs["date"], "status": self.common_time_entry_kwargs["status"], "startTime": self.common_time_entry_kwargs["start_time"], - "duration": 30 / 60, # self.common_time_entry_kwargs["duration"] + "duration": 25, # self.common_time_entry_kwargs["duration"] "userId": self.gID(self.user.pk), "taskId": self.gID(self.active_tasks[0].pk), } diff --git a/apps/track/tests/test_queries.py b/apps/track/tests/test_queries.py index 1c65877..89164df 100644 --- a/apps/track/tests/test_queries.py +++ b/apps/track/tests/test_queries.py @@ -195,7 +195,7 @@ def test_my_time_entries(self): common_kwargs = dict( user=self.user, type=TimeEntry.Type.DEVELOPMENT, - duration=30, + duration=45, task=task, date=date, ) @@ -236,7 +236,7 @@ def test_my_time_entries(self): displayName=self.gID(self.user.display_name), ), status=self.genum(entry.status), - duration=30 / 60, + duration=45, description=None, startTime=None, ) diff --git a/schema.graphql b/schema.graphql index c8c6951..8dafd61 100644 --- a/schema.graphql +++ b/schema.graphql @@ -463,9 +463,7 @@ type TaskTypeCountList { """Time (isoformat)""" scalar Time -""" -The `TimeDuration` scalar type represents Duration values in hours, The value is stored in minute in database -""" +"""The `TimeDuration` scalar type represents Duration values in minutes""" scalar TimeDuration input TimeEntryBulkCreateInput { diff --git a/utils/strawberry/serializers.py b/utils/strawberry/serializers.py index 443a03a..25f6d5d 100644 --- a/utils/strawberry/serializers.py +++ b/utils/strawberry/serializers.py @@ -31,9 +31,9 @@ def run_validation(self, data=serializers.empty): return super().run_validation(data) -class TimeDurationField(serializers.FloatField): +class TimeDurationField(serializers.IntegerField): """ - This field is created to override the graphene conversion of the floatfield -> TimeDurationField + This field is created to override the graphene conversion of the integerfield -> TimeDurationField """ pass diff --git a/utils/strawberry/transformers.py b/utils/strawberry/transformers.py index 2d17198..1963641 100644 --- a/utils/strawberry/transformers.py +++ b/utils/strawberry/transformers.py @@ -68,12 +68,6 @@ def convert_serializer_field_to_generic_scalar(_): return types.GenericScalar -# XXX: Custom field -@get_strawberry_type_from_serializer_field.register(TimeDurationField) # type: ignore[reportArgumentType] -def convert_serializer_field_to_duration(_): - return types.TimeDuration - - @get_strawberry_type_from_serializer_field.register(serializers.Field) # type: ignore[reportArgumentType] def convert_serializer_field_to_string(_): return str @@ -133,6 +127,13 @@ def convert_serializer_field_to_enum(field): return ENUM_TO_STRAWBERRY_ENUM_MAP[custom_name] +# --------- Custom field +# This is used just for description +@get_strawberry_type_from_serializer_field.register(TimeDurationField) # type: ignore[reportArgumentType] +def convert_serializer_field_to_duration(_): + return types.TimeDuration + + convert_serializer_to_type_cache = {} diff --git a/utils/strawberry/types.py b/utils/strawberry/types.py index baaf458..aca45ad 100644 --- a/utils/strawberry/types.py +++ b/utils/strawberry/types.py @@ -17,14 +17,10 @@ parse_value=lambda v: v, ) +# This is used to provide description only TimeDuration = strawberry.scalar( - typing.NewType("TimeDuration", float), - description=( - "The `TimeDuration` scalar type represents Duration values in hours," " The value is stored in minute in database" - ), - # NOTE: v is in minutes - serialize=lambda v: v / 60, # From server - parse_value=lambda v: v * 60, # From client + typing.NewType("TimeDuration", int), + description="The `TimeDuration` scalar type represents Duration values in minutes", ) From a8ec3a8e7023de6848d8916055d5456635db615c Mon Sep 17 00:00:00 2001 From: thenav56 Date: Wed, 14 Aug 2024 18:43:23 +0545 Subject: [PATCH 24/51] Avoid auto string whitespace trimming --- apps/track/mutations.py | 1 + apps/track/serializers.py | 7 ++++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/track/mutations.py b/apps/track/mutations.py index 61fd2dd..d9d60e8 100644 --- a/apps/track/mutations.py +++ b/apps/track/mutations.py @@ -44,6 +44,7 @@ async def bulk_time_entry( delete_ids: list[strawberry.ID] | None = [], ) -> BulkMutationResponseType[TimeEntryType]: queryset = TimeEntryType.get_queryset(None, None, info).filter(user=info.context.request.user) + return await TimeEntryBulkMutation.handle_bulk_mutation( queryset, items, diff --git a/apps/track/serializers.py b/apps/track/serializers.py index 87e037e..0f921c7 100644 --- a/apps/track/serializers.py +++ b/apps/track/serializers.py @@ -1,7 +1,11 @@ from rest_framework import serializers from apps.common.serializers import TempClientIdMixin -from utils.strawberry.serializers import IntegerIDField, TimeDurationField +from utils.strawberry.serializers import ( + CustomCharField, + IntegerIDField, + TimeDurationField, +) from .models import TimeEntry @@ -9,6 +13,7 @@ class TimeEntrySerializer(TempClientIdMixin, serializers.ModelSerializer): # Used just for adding description duration = TimeDurationField(required=False, allow_null=True) + description = CustomCharField(required=False, allow_blank=True, trim_whitespace=False) class Meta: # type: ignore[reportIncompatibleVariab] model = TimeEntry From 64a46504f47fa8c7847e2b7faddd765a1ac687e4 Mon Sep 17 00:00:00 2001 From: thenav56 Date: Thu, 15 Aug 2024 21:44:21 +0545 Subject: [PATCH 25/51] Add project logo --- apps/project/migrations/0004_project_logo.py | 18 ++++ apps/project/models.py | 6 ++ apps/project/types.py | 1 + .../migrations/0013_contract_description.py | 18 ++++ apps/track/models.py | 1 + apps/track/tests/test_mutations.py | 4 +- apps/track/types.py | 1 + poetry.lock | 99 ++++++++++++++++++- pyproject.toml | 1 + schema.graphql | 11 +++ 10 files changed, 157 insertions(+), 3 deletions(-) create mode 100644 apps/project/migrations/0004_project_logo.py create mode 100644 apps/track/migrations/0013_contract_description.py diff --git a/apps/project/migrations/0004_project_logo.py b/apps/project/migrations/0004_project_logo.py new file mode 100644 index 0000000..59c1e70 --- /dev/null +++ b/apps/project/migrations/0004_project_logo.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.15 on 2024-08-15 15:57 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("project", "0003_rename_client_project_project_client"), + ] + + operations = [ + migrations.AddField( + model_name="project", + name="logo", + field=models.ImageField(blank=True, max_length=255, null=True, upload_to="project/logo/"), + ), + ] diff --git a/apps/project/models.py b/apps/project/models.py index 876c8f8..7dcfd7f 100644 --- a/apps/project/models.py +++ b/apps/project/models.py @@ -20,6 +20,12 @@ def __str__(self): class Project(UserResource): name = models.CharField(max_length=225) description = models.TextField(blank=True) + logo = models.ImageField( + upload_to="project/logo/", + max_length=255, + blank=True, + null=True, + ) # NOTE: We use `client_id` for storing client context information temporary. # This may collide in future. So, using `project_client` instead of just `client` diff --git a/apps/project/types.py b/apps/project/types.py index fbfe51e..f5e28c5 100644 --- a/apps/project/types.py +++ b/apps/project/types.py @@ -35,6 +35,7 @@ def get_queryset(_, queryset: models.QuerySet | None, info: Info): @strawberry_django.type(Project) class ProjectType(UserResourceTypeMixin): id: strawberry.ID + logo: strawberry.auto project_client_id: strawberry.ID contractor_id: strawberry.ID diff --git a/apps/track/migrations/0013_contract_description.py b/apps/track/migrations/0013_contract_description.py new file mode 100644 index 0000000..18b06bd --- /dev/null +++ b/apps/track/migrations/0013_contract_description.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.15 on 2024-08-15 15:57 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("track", "0012_alter_timeentry_duration"), + ] + + operations = [ + migrations.AddField( + model_name="contract", + name="description", + field=models.TextField(blank=True), + ), + ] diff --git a/apps/track/models.py b/apps/track/models.py index 988c501..5f82c27 100644 --- a/apps/track/models.py +++ b/apps/track/models.py @@ -8,6 +8,7 @@ class Contract(UserResource): name = models.CharField(max_length=225) + description = models.TextField(blank=True) project = models.ForeignKey(Project, on_delete=models.PROTECT, related_name="contracts") total_estimated_hours = models.FloatField(null=True, blank=True) is_archived = models.BooleanField(default=False) diff --git a/apps/track/tests/test_mutations.py b/apps/track/tests/test_mutations.py index 9446407..15e81e4 100644 --- a/apps/track/tests/test_mutations.py +++ b/apps/track/tests/test_mutations.py @@ -78,7 +78,7 @@ def setUpClass(cls): task=cls.active_tasks[1], date="2021-01-02", type=TimeEntry.Type.DEVELOPMENT, - description="Norm description", + description="Norm description ", status=TimeEntry.Status.DOING, duration=30, start_time="09:30:00", @@ -109,7 +109,7 @@ def test_bulk_time_entry_create(self): task=self.gID(self.active_tasks[0].pk), date="2021-01-01", type=self.genum(TimeEntry.Type.DEVELOPMENT), - description="Normal description", + description="Normal description ", status=self.genum(TimeEntry.Status.DOING), duration=30, startTime="09:30:00", diff --git a/apps/track/types.py b/apps/track/types.py index f7cd0f7..edf28ff 100644 --- a/apps/track/types.py +++ b/apps/track/types.py @@ -21,6 +21,7 @@ class ContractType(UserResourceTypeMixin): is_archived: strawberry.auto name = string_field(Contract.name) + description = string_field(Contract.description) @staticmethod def get_queryset(_, queryset: models.QuerySet | None, info: Info): diff --git a/poetry.lock b/poetry.lock index c7cad54..0fdbe78 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1430,6 +1430,103 @@ files = [ [package.dependencies] ptyprocess = ">=0.5" +[[package]] +name = "pillow" +version = "10.4.0" +description = "Python Imaging Library (Fork)" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pillow-10.4.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:4d9667937cfa347525b319ae34375c37b9ee6b525440f3ef48542fcf66f2731e"}, + {file = "pillow-10.4.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:543f3dc61c18dafb755773efc89aae60d06b6596a63914107f75459cf984164d"}, + {file = "pillow-10.4.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7928ecbf1ece13956b95d9cbcfc77137652b02763ba384d9ab508099a2eca856"}, + {file = "pillow-10.4.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e4d49b85c4348ea0b31ea63bc75a9f3857869174e2bf17e7aba02945cd218e6f"}, + {file = "pillow-10.4.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:6c762a5b0997f5659a5ef2266abc1d8851ad7749ad9a6a5506eb23d314e4f46b"}, + {file = "pillow-10.4.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:a985e028fc183bf12a77a8bbf36318db4238a3ded7fa9df1b9a133f1cb79f8fc"}, + {file = "pillow-10.4.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:812f7342b0eee081eaec84d91423d1b4650bb9828eb53d8511bcef8ce5aecf1e"}, + {file = "pillow-10.4.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ac1452d2fbe4978c2eec89fb5a23b8387aba707ac72810d9490118817d9c0b46"}, + {file = "pillow-10.4.0-cp310-cp310-win32.whl", hash = "sha256:bcd5e41a859bf2e84fdc42f4edb7d9aba0a13d29a2abadccafad99de3feff984"}, + {file = "pillow-10.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:ecd85a8d3e79cd7158dec1c9e5808e821feea088e2f69a974db5edf84dc53141"}, + {file = "pillow-10.4.0-cp310-cp310-win_arm64.whl", hash = "sha256:ff337c552345e95702c5fde3158acb0625111017d0e5f24bf3acdb9cc16b90d1"}, + {file = "pillow-10.4.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:0a9ec697746f268507404647e531e92889890a087e03681a3606d9b920fbee3c"}, + {file = "pillow-10.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dfe91cb65544a1321e631e696759491ae04a2ea11d36715eca01ce07284738be"}, + {file = "pillow-10.4.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5dc6761a6efc781e6a1544206f22c80c3af4c8cf461206d46a1e6006e4429ff3"}, + {file = "pillow-10.4.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5e84b6cc6a4a3d76c153a6b19270b3526a5a8ed6b09501d3af891daa2a9de7d6"}, + {file = "pillow-10.4.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:bbc527b519bd3aa9d7f429d152fea69f9ad37c95f0b02aebddff592688998abe"}, + {file = "pillow-10.4.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:76a911dfe51a36041f2e756b00f96ed84677cdeb75d25c767f296c1c1eda1319"}, + {file = "pillow-10.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:59291fb29317122398786c2d44427bbd1a6d7ff54017075b22be9d21aa59bd8d"}, + {file = "pillow-10.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:416d3a5d0e8cfe4f27f574362435bc9bae57f679a7158e0096ad2beb427b8696"}, + {file = "pillow-10.4.0-cp311-cp311-win32.whl", hash = "sha256:7086cc1d5eebb91ad24ded9f58bec6c688e9f0ed7eb3dbbf1e4800280a896496"}, + {file = "pillow-10.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:cbed61494057c0f83b83eb3a310f0bf774b09513307c434d4366ed64f4128a91"}, + {file = "pillow-10.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:f5f0c3e969c8f12dd2bb7e0b15d5c468b51e5017e01e2e867335c81903046a22"}, + {file = "pillow-10.4.0-cp312-cp312-macosx_10_10_x86_64.whl", hash = "sha256:673655af3eadf4df6b5457033f086e90299fdd7a47983a13827acf7459c15d94"}, + {file = "pillow-10.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:866b6942a92f56300012f5fbac71f2d610312ee65e22f1aa2609e491284e5597"}, + {file = "pillow-10.4.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29dbdc4207642ea6aad70fbde1a9338753d33fb23ed6956e706936706f52dd80"}, + {file = "pillow-10.4.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bf2342ac639c4cf38799a44950bbc2dfcb685f052b9e262f446482afaf4bffca"}, + {file = "pillow-10.4.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:f5b92f4d70791b4a67157321c4e8225d60b119c5cc9aee8ecf153aace4aad4ef"}, + {file = "pillow-10.4.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:86dcb5a1eb778d8b25659d5e4341269e8590ad6b4e8b44d9f4b07f8d136c414a"}, + {file = "pillow-10.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:780c072c2e11c9b2c7ca37f9a2ee8ba66f44367ac3e5c7832afcfe5104fd6d1b"}, + {file = "pillow-10.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:37fb69d905be665f68f28a8bba3c6d3223c8efe1edf14cc4cfa06c241f8c81d9"}, + {file = "pillow-10.4.0-cp312-cp312-win32.whl", hash = "sha256:7dfecdbad5c301d7b5bde160150b4db4c659cee2b69589705b6f8a0c509d9f42"}, + {file = "pillow-10.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:1d846aea995ad352d4bdcc847535bd56e0fd88d36829d2c90be880ef1ee4668a"}, + {file = "pillow-10.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:e553cad5179a66ba15bb18b353a19020e73a7921296a7979c4a2b7f6a5cd57f9"}, + {file = "pillow-10.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8bc1a764ed8c957a2e9cacf97c8b2b053b70307cf2996aafd70e91a082e70df3"}, + {file = "pillow-10.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6209bb41dc692ddfee4942517c19ee81b86c864b626dbfca272ec0f7cff5d9fb"}, + {file = "pillow-10.4.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bee197b30783295d2eb680b311af15a20a8b24024a19c3a26431ff83eb8d1f70"}, + {file = "pillow-10.4.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1ef61f5dd14c300786318482456481463b9d6b91ebe5ef12f405afbba77ed0be"}, + {file = "pillow-10.4.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:297e388da6e248c98bc4a02e018966af0c5f92dfacf5a5ca22fa01cb3179bca0"}, + {file = "pillow-10.4.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:e4db64794ccdf6cb83a59d73405f63adbe2a1887012e308828596100a0b2f6cc"}, + {file = "pillow-10.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bd2880a07482090a3bcb01f4265f1936a903d70bc740bfcb1fd4e8a2ffe5cf5a"}, + {file = "pillow-10.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4b35b21b819ac1dbd1233317adeecd63495f6babf21b7b2512d244ff6c6ce309"}, + {file = "pillow-10.4.0-cp313-cp313-win32.whl", hash = "sha256:551d3fd6e9dc15e4c1eb6fc4ba2b39c0c7933fa113b220057a34f4bb3268a060"}, + {file = "pillow-10.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:030abdbe43ee02e0de642aee345efa443740aa4d828bfe8e2eb11922ea6a21ea"}, + {file = "pillow-10.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:5b001114dd152cfd6b23befeb28d7aee43553e2402c9f159807bf55f33af8a8d"}, + {file = "pillow-10.4.0-cp38-cp38-macosx_10_10_x86_64.whl", hash = "sha256:8d4d5063501b6dd4024b8ac2f04962d661222d120381272deea52e3fc52d3736"}, + {file = "pillow-10.4.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:7c1ee6f42250df403c5f103cbd2768a28fe1a0ea1f0f03fe151c8741e1469c8b"}, + {file = "pillow-10.4.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b15e02e9bb4c21e39876698abf233c8c579127986f8207200bc8a8f6bb27acf2"}, + {file = "pillow-10.4.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a8d4bade9952ea9a77d0c3e49cbd8b2890a399422258a77f357b9cc9be8d680"}, + {file = "pillow-10.4.0-cp38-cp38-manylinux_2_28_aarch64.whl", hash = "sha256:43efea75eb06b95d1631cb784aa40156177bf9dd5b4b03ff38979e048258bc6b"}, + {file = "pillow-10.4.0-cp38-cp38-manylinux_2_28_x86_64.whl", hash = "sha256:950be4d8ba92aca4b2bb0741285a46bfae3ca699ef913ec8416c1b78eadd64cd"}, + {file = "pillow-10.4.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:d7480af14364494365e89d6fddc510a13e5a2c3584cb19ef65415ca57252fb84"}, + {file = "pillow-10.4.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:73664fe514b34c8f02452ffb73b7a92c6774e39a647087f83d67f010eb9a0cf0"}, + {file = "pillow-10.4.0-cp38-cp38-win32.whl", hash = "sha256:e88d5e6ad0d026fba7bdab8c3f225a69f063f116462c49892b0149e21b6c0a0e"}, + {file = "pillow-10.4.0-cp38-cp38-win_amd64.whl", hash = "sha256:5161eef006d335e46895297f642341111945e2c1c899eb406882a6c61a4357ab"}, + {file = "pillow-10.4.0-cp39-cp39-macosx_10_10_x86_64.whl", hash = "sha256:0ae24a547e8b711ccaaf99c9ae3cd975470e1a30caa80a6aaee9a2f19c05701d"}, + {file = "pillow-10.4.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:298478fe4f77a4408895605f3482b6cc6222c018b2ce565c2b6b9c354ac3229b"}, + {file = "pillow-10.4.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:134ace6dc392116566980ee7436477d844520a26a4b1bd4053f6f47d096997fd"}, + {file = "pillow-10.4.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:930044bb7679ab003b14023138b50181899da3f25de50e9dbee23b61b4de2126"}, + {file = "pillow-10.4.0-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:c76e5786951e72ed3686e122d14c5d7012f16c8303a674d18cdcd6d89557fc5b"}, + {file = "pillow-10.4.0-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:b2724fdb354a868ddf9a880cb84d102da914e99119211ef7ecbdc613b8c96b3c"}, + {file = "pillow-10.4.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:dbc6ae66518ab3c5847659e9988c3b60dc94ffb48ef9168656e0019a93dbf8a1"}, + {file = "pillow-10.4.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:06b2f7898047ae93fad74467ec3d28fe84f7831370e3c258afa533f81ef7f3df"}, + {file = "pillow-10.4.0-cp39-cp39-win32.whl", hash = "sha256:7970285ab628a3779aecc35823296a7869f889b8329c16ad5a71e4901a3dc4ef"}, + {file = "pillow-10.4.0-cp39-cp39-win_amd64.whl", hash = "sha256:961a7293b2457b405967af9c77dcaa43cc1a8cd50d23c532e62d48ab6cdd56f5"}, + {file = "pillow-10.4.0-cp39-cp39-win_arm64.whl", hash = "sha256:32cda9e3d601a52baccb2856b8ea1fc213c90b340c542dcef77140dfa3278a9e"}, + {file = "pillow-10.4.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:5b4815f2e65b30f5fbae9dfffa8636d992d49705723fe86a3661806e069352d4"}, + {file = "pillow-10.4.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:8f0aef4ef59694b12cadee839e2ba6afeab89c0f39a3adc02ed51d109117b8da"}, + {file = "pillow-10.4.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9f4727572e2918acaa9077c919cbbeb73bd2b3ebcfe033b72f858fc9fbef0026"}, + {file = "pillow-10.4.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ff25afb18123cea58a591ea0244b92eb1e61a1fd497bf6d6384f09bc3262ec3e"}, + {file = "pillow-10.4.0-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:dc3e2db6ba09ffd7d02ae9141cfa0ae23393ee7687248d46a7507b75d610f4f5"}, + {file = "pillow-10.4.0-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:02a2be69f9c9b8c1e97cf2713e789d4e398c751ecfd9967c18d0ce304efbf885"}, + {file = "pillow-10.4.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:0755ffd4a0c6f267cccbae2e9903d95477ca2f77c4fcf3a3a09570001856c8a5"}, + {file = "pillow-10.4.0-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:a02364621fe369e06200d4a16558e056fe2805d3468350df3aef21e00d26214b"}, + {file = "pillow-10.4.0-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:1b5dea9831a90e9d0721ec417a80d4cbd7022093ac38a568db2dd78363b00908"}, + {file = "pillow-10.4.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9b885f89040bb8c4a1573566bbb2f44f5c505ef6e74cec7ab9068c900047f04b"}, + {file = "pillow-10.4.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:87dd88ded2e6d74d31e1e0a99a726a6765cda32d00ba72dc37f0651f306daaa8"}, + {file = "pillow-10.4.0-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:2db98790afc70118bd0255c2eeb465e9767ecf1f3c25f9a1abb8ffc8cfd1fe0a"}, + {file = "pillow-10.4.0-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:f7baece4ce06bade126fb84b8af1c33439a76d8a6fd818970215e0560ca28c27"}, + {file = "pillow-10.4.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:cfdd747216947628af7b259d274771d84db2268ca062dd5faf373639d00113a3"}, + {file = "pillow-10.4.0.tar.gz", hash = "sha256:166c1cd4d24309b30d61f79f4a9114b7b2313d7450912277855ff5dfd7cd4a06"}, +] + +[package.extras] +docs = ["furo", "olefile", "sphinx (>=7.3)", "sphinx-copybutton", "sphinx-inline-tabs", "sphinxext-opengraph"] +fpx = ["olefile"] +mic = ["olefile"] +tests = ["check-manifest", "coverage", "defusedxml", "markdown2", "olefile", "packaging", "pyroma", "pytest", "pytest-cov", "pytest-timeout"] +typing = ["typing-extensions"] +xmp = ["defusedxml"] + [[package]] name = "platformdirs" version = "4.2.2" @@ -2247,4 +2344,4 @@ files = [ [metadata] lock-version = "2.0" python-versions = "^3.12" -content-hash = "bad68d4716a3219d3af40a221f3f1fd1dee0979243c452d192c754a212795725" +content-hash = "65e3a281b1e0649765a28715674de4c21d898f058736f7d02f567c5fbb82dd15" diff --git a/pyproject.toml b/pyproject.toml index e7d4e47..a918422 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,6 +32,7 @@ uwsgi = "*" aws-sns-message-validator = "*" google-api-python-client = "*" django-health-check = "*" +Pillow = "*" # Required by django ImageField psutil = "*" # Required by django-health-check [tool.poetry.dev-dependencies] diff --git a/schema.graphql b/schema.graphql index 8dafd61..31b3e2a 100644 --- a/schema.graphql +++ b/schema.graphql @@ -92,6 +92,7 @@ type ContractType implements UserResourceTypeMixin { totalEstimatedHours: Float isArchived: Boolean! name: String! + description: String project: ProjectType! """Sum of all task's estimated hours under this contract""" @@ -185,6 +186,15 @@ input DateRangeLookup { """Date with time (isoformat)""" scalar DateTime +type DjangoImageType { + name: String! + path: String! + size: Int! + url: String! + width: Int! + height: Int! +} + input DjangoModelFilterInput { pk: ID! } @@ -343,6 +353,7 @@ type ProjectType implements UserResourceTypeMixin { createdBy: UserType! modifiedBy: UserType! id: ID! + logo: DjangoImageType projectClientId: ID! contractorId: ID! name: String! From 23efff058252c630404fee25e5fc057b74590a8a Mon Sep 17 00:00:00 2001 From: thenav56 Date: Tue, 20 Aug 2024 21:13:43 +0545 Subject: [PATCH 26/51] Add EXTERNAL_DISCUSSION as time entry type --- apps/track/migrations/0011_alter_timeentry_type.py | 1 + apps/track/models.py | 1 + schema.graphql | 1 + 3 files changed, 3 insertions(+) diff --git a/apps/track/migrations/0011_alter_timeentry_type.py b/apps/track/migrations/0011_alter_timeentry_type.py index e4ac09f..635b1ca 100644 --- a/apps/track/migrations/0011_alter_timeentry_type.py +++ b/apps/track/migrations/0011_alter_timeentry_type.py @@ -20,6 +20,7 @@ class Migration(migrations.Migration): (1200, "DevOps"), (2000, "Documentation"), (3000, "Internal Discussion"), + (3100, "External Discussion"), (4000, "Meeting"), (5000, "Project Management"), (6000, "QA"), diff --git a/apps/track/models.py b/apps/track/models.py index 5f82c27..edfe145 100644 --- a/apps/track/models.py +++ b/apps/track/models.py @@ -41,6 +41,7 @@ class Type(models.IntegerChoices): DEV_OPS = 1200, _("DevOps") DOCUMENTATION = 2000, _("Documentation") INTERNAL_DISCUSSION = 3000, _("Internal Discussion") + EXTERNAL_DISCUSSION = 3100, _("External Discussion") MEETING = 4000, _("Meeting") PROJECT_MANAGEMENT = 5000, _("Project Management") QUALITY_ASSURANCE = 6000, _("QA") diff --git a/schema.graphql b/schema.graphql index 31b3e2a..e8ac227 100644 --- a/schema.graphql +++ b/schema.graphql @@ -562,6 +562,7 @@ enum TimeEntryTypeEnum { DEV_OPS DOCUMENTATION INTERNAL_DISCUSSION + EXTERNAL_DISCUSSION MEETING PROJECT_MANAGEMENT QUALITY_ASSURANCE From 2353801ca6b5620726171887a4cbe6aa49d07c20 Mon Sep 17 00:00:00 2001 From: thenav56 Date: Wed, 21 Aug 2024 17:35:38 +0545 Subject: [PATCH 27/51] Respond with absolute url for FileField --- .gitignore | 2 ++ schema.graphql | 1 - utils/strawberry/types.py | 34 ++++++++++++++++++++++++++++++++++ 3 files changed, 36 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 24b6742..96fda31 100644 --- a/.gitignore +++ b/.gitignore @@ -129,3 +129,5 @@ dmypy.json # editors .idea/ + +assets/ diff --git a/schema.graphql b/schema.graphql index e8ac227..f8f0582 100644 --- a/schema.graphql +++ b/schema.graphql @@ -188,7 +188,6 @@ scalar DateTime type DjangoImageType { name: String! - path: String! size: Int! url: String! width: Int! diff --git a/utils/strawberry/types.py b/utils/strawberry/types.py index aca45ad..d0fc2f0 100644 --- a/utils/strawberry/types.py +++ b/utils/strawberry/types.py @@ -3,8 +3,13 @@ import strawberry from django.contrib.gis.geos import GEOSGeometry +from django.core.files.storage import FileSystemStorage, default_storage from django.db import models from django.db.models.fields import Field as DjangoBaseField +from django.db.models.fields import files +from strawberry_django.fields.types import field_type_map + +from main.graphql.context import Info if typing.TYPE_CHECKING: from django.db.models.fields import _FieldDescriptor @@ -81,3 +86,32 @@ def nullable_string_(root) -> typing.Optional[str]: if _field.null or _field.blank: # type: ignore[reportGeneralTypeIssues] FIXME return nullable_string_ return string_ + + +@strawberry.type +class DjangoFileType: + name: str + size: int + + @strawberry.field + @staticmethod + def url(root: files.FieldFile, info: Info) -> str: + # TODO: Use cache if using S3 with signatured URL + if isinstance(default_storage, FileSystemStorage): + return info.context.request.build_absolute_uri(root.url) + return root.url + + +@strawberry.type +class DjangoImageType(DjangoFileType): + width: int + height: int + + +# Update the strawberry django field type mapping +field_type_map.update( + { + files.FileField: DjangoFileType, + files.ImageField: DjangoImageType, + } +) From 6c959dc4ef708185568c2d5859dd11947f38a8e0 Mon Sep 17 00:00:00 2001 From: thenav56 Date: Thu, 22 Aug 2024 17:16:46 +0545 Subject: [PATCH 28/51] Add support for duration adjustment for reporting --- apps/track/admin.py | 1 + .../0014_timeentry_duration_adjustment.py | 22 +++++++++++++++++++ apps/track/models.py | 20 +++++++++++++++++ 3 files changed, 43 insertions(+) create mode 100644 apps/track/migrations/0014_timeentry_duration_adjustment.py diff --git a/apps/track/admin.py b/apps/track/admin.py index 296ab90..5124ec1 100644 --- a/apps/track/admin.py +++ b/apps/track/admin.py @@ -89,6 +89,7 @@ class TimeEntryAdmin(admin.ModelAdmin): "type", "date", "duration", + "duration_adjustment", "status", ) diff --git a/apps/track/migrations/0014_timeentry_duration_adjustment.py b/apps/track/migrations/0014_timeentry_duration_adjustment.py new file mode 100644 index 0000000..1d76caf --- /dev/null +++ b/apps/track/migrations/0014_timeentry_duration_adjustment.py @@ -0,0 +1,22 @@ +# Generated by Django 4.2.15 on 2024-08-22 11:29 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("track", "0013_contract_description"), + ] + + operations = [ + migrations.AddField( + model_name="timeentry", + name="duration_adjustment", + field=models.SmallIntegerField( + blank=True, + help_text="Minutes. Used to keep track of reported minutes. This will be used as duration (+- duration_adjustment)", + null=True, + ), + ), + ] diff --git a/apps/track/models.py b/apps/track/models.py index edfe145..0a36855 100644 --- a/apps/track/models.py +++ b/apps/track/models.py @@ -1,3 +1,4 @@ +from django.core.exceptions import ValidationError from django.db import models from django.utils.translation import gettext_lazy as _ @@ -71,6 +72,13 @@ class Status(models.IntegerChoices): blank=True, help_text=_("Minutes"), ) + duration_adjustment = models.SmallIntegerField( + null=True, + blank=True, + help_text=_( + "Minutes. Used to keep track of reported minutes. This will be used as duration (+- duration_adjustment)" + ), + ) user_id: int task_id: int @@ -78,3 +86,15 @@ class Status(models.IntegerChoices): class Meta: # type: ignore[reportIncompatibleVariab] verbose_name = _("time entry") verbose_name_plural = _("time entries") + + def clean(self): + super().clean() + + # Make sure duration is defined before having duration_adjustment + if self.duration_adjustment is not None and self.duration is None: + raise ValidationError(_("Duration needs to be defined before using Duration (Adjustment)")) + + # Make sure duration + duration_adjustment doesn't have negative value + if self.duration is not None and self.duration_adjustment is not None: + if self.duration_adjustment + self.duration < 0: + raise ValidationError(_("Duration adjustment shouldn't generate negative duration")) From 5ce8f1aec36a8ee62abe4cd0a1e77d3ab44f2b16 Mon Sep 17 00:00:00 2001 From: thenav56 Date: Thu, 22 Aug 2024 23:56:13 +0545 Subject: [PATCH 29/51] DailyStandUp first draft --- apps/common/migrations/0001_initial.py | 22 ++++ apps/common/models.py | 11 ++ apps/journal/admin.py | 13 +++ apps/journal/dataloaders.py | 49 +++++++++ apps/journal/enums.py | 9 +- .../migrations/0003_journal_wfh_type.py | 20 ++++ apps/journal/models.py | 31 ++++++ apps/journal/serializers.py | 1 + apps/journal/types.py | 2 + apps/project/admin.py | 16 ++- apps/project/dataloaders.py | 17 ++- .../0005_project_is_archived_deadline.py | 70 ++++++++++++ apps/project/models.py | 19 ++++ apps/project/queries.py | 13 ++- apps/project/types.py | 41 ++++++- apps/standup/admin.py | 16 +++ apps/standup/migrations/0001_initial.py | 47 ++++++++ apps/standup/migrations/__init__.py | 0 apps/standup/models.py | 9 +- apps/standup/queries.py | 14 +++ apps/standup/types.py | 102 ++++++++++++++++++ apps/track/models.py | 2 + main/graphql/dataloaders.py | 5 + main/graphql/schema.py | 2 + schema.graphql | 63 +++++++++++ utils/strawberry/types.py | 2 +- 26 files changed, 586 insertions(+), 10 deletions(-) create mode 100644 apps/common/migrations/0001_initial.py create mode 100644 apps/journal/dataloaders.py create mode 100644 apps/journal/migrations/0003_journal_wfh_type.py create mode 100644 apps/project/migrations/0005_project_is_archived_deadline.py create mode 100644 apps/standup/admin.py create mode 100644 apps/standup/migrations/0001_initial.py create mode 100644 apps/standup/migrations/__init__.py create mode 100644 apps/standup/queries.py create mode 100644 apps/standup/types.py diff --git a/apps/common/migrations/0001_initial.py b/apps/common/migrations/0001_initial.py new file mode 100644 index 0000000..9d6048c --- /dev/null +++ b/apps/common/migrations/0001_initial.py @@ -0,0 +1,22 @@ +# Generated by Django 4.2.15 on 2024-08-22 17:47 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [] + + operations = [ + migrations.CreateModel( + name="Event", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("name", models.CharField(max_length=225)), + ("date", models.DateField()), + ("type", models.PositiveSmallIntegerField(choices=[(1, "Holiday"), (2, "Retreat"), (3, "Misc")], default=1)), + ], + ), + ] diff --git a/apps/common/models.py b/apps/common/models.py index 74b5af9..e81523d 100644 --- a/apps/common/models.py +++ b/apps/common/models.py @@ -26,3 +26,14 @@ class UserResource(models.Model): class Meta: # type: ignore[reportIncompatibleVariableOverride] abstract = True ordering = ["-id"] + + +class Event(models.Model): + class Type(models.IntegerChoices): + HOLIDAY = 1, "Holiday" + RETREAT = 2, "Retreat" + MISC = 3, "Misc" + + name = models.CharField(max_length=225) + date = models.DateField() + type = models.PositiveSmallIntegerField(choices=Type.choices, default=Type.HOLIDAY) diff --git a/apps/journal/admin.py b/apps/journal/admin.py index e69de29..d86173c 100644 --- a/apps/journal/admin.py +++ b/apps/journal/admin.py @@ -0,0 +1,13 @@ +from django.contrib import admin + +from apps.common.admin import PreventDeleteAdminMixin, VersionAdmin + +from .models import Journal + + +@admin.register(Journal) +class JournalAdmin(PreventDeleteAdminMixin, VersionAdmin): + search_fields = ("user",) + list_display = ("user", "date") + + autocomplete_fields = ("user",) diff --git a/apps/journal/dataloaders.py b/apps/journal/dataloaders.py new file mode 100644 index 0000000..8439181 --- /dev/null +++ b/apps/journal/dataloaders.py @@ -0,0 +1,49 @@ +import datetime + +from asgiref.sync import sync_to_async +from django.utils.functional import cached_property +from strawberry.dataloader import DataLoader + +from .models import Journal + + +def load_user_leave(keys: list[tuple[int, datetime.date]]) -> list[Journal.LeaveType | None]: + user_ids = [] + dates = [] + for user_id, date in keys: + user_ids.append(user_id) + dates.append(date) + + qs = Journal.objects.filter( + user__in=user_ids, + date__in=dates, + ).values_list("user_id", "date", "leave_type") + + _map = {(user_id, date): leave_type for user_id, date, leave_type in qs} + return [_map.get(key) for key in keys] + + +def load_user_work_from_home(keys: list[tuple[int, datetime.date]]) -> list[Journal.WorkFromHomeType | None]: + user_ids = [] + dates = [] + for user_id, date in keys: + user_ids.append(user_id) + dates.append(date) + + qs = Journal.objects.filter( + user__in=user_ids, + date__in=dates, + ).values_list("user_id", "date", "wfh_type") + + _map = {(user_id, date): wfh_type for user_id, date, wfh_type in qs} + return [_map.get(key) for key in keys] + + +class JournalDataLoader: + @cached_property + def load_user_leave(self): + return DataLoader(load_fn=sync_to_async(load_user_leave)) + + @cached_property + def load_user_work_from_home(self): + return DataLoader(load_fn=sync_to_async(load_user_work_from_home)) diff --git a/apps/journal/enums.py b/apps/journal/enums.py index 5c162ce..58f81ee 100644 --- a/apps/journal/enums.py +++ b/apps/journal/enums.py @@ -5,6 +5,13 @@ from .models import Journal JournalLeaveTypeEnum = strawberry.enum(Journal.LeaveType, name="JournalLeaveTypeEnum") +JournalWorkFromHomeTypeEnum = strawberry.enum(Journal.WorkFromHomeType, name="JournalWorkFromHomeTypeEnum") -enum_map = {get_enum_name_from_django_field(field): enum for field, enum in ((Journal.leave_type, JournalLeaveTypeEnum),)} +enum_map = { + get_enum_name_from_django_field(field): enum + for field, enum in ( + (Journal.leave_type, JournalLeaveTypeEnum), + (Journal.wfh_type, JournalWorkFromHomeTypeEnum), + ) +} diff --git a/apps/journal/migrations/0003_journal_wfh_type.py b/apps/journal/migrations/0003_journal_wfh_type.py new file mode 100644 index 0000000..61cf5d4 --- /dev/null +++ b/apps/journal/migrations/0003_journal_wfh_type.py @@ -0,0 +1,20 @@ +# Generated by Django 4.2.15 on 2024-08-22 17:47 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("journal", "0002_initial"), + ] + + operations = [ + migrations.AddField( + model_name="journal", + name="wfh_type", + field=models.PositiveSmallIntegerField( + blank=True, choices=[(1, "Full"), (2, "First Half"), (3, "Second Half")], null=True + ), + ), + ] diff --git a/apps/journal/models.py b/apps/journal/models.py index f989fff..6b666a4 100644 --- a/apps/journal/models.py +++ b/apps/journal/models.py @@ -1,3 +1,4 @@ +from django.core.exceptions import ValidationError from django.db import models from django.utils.translation import gettext_lazy as _ @@ -11,10 +12,32 @@ class LeaveType(models.IntegerChoices): FIRST_HALF = 2, _("First Half") SECOND_HALF = 3, _("Second Half") + class WorkFromHomeType(models.IntegerChoices): + FULL = 1, _("Full") + FIRST_HALF = 2, _("First Half") + SECOND_HALF = 3, _("Second Half") + + VALID_LEAVE_WFH_COMBINATION = set( + [ + # -- FULL + (LeaveType.FULL, None), + (None, WorkFromHomeType.FULL), + # -- FH + (LeaveType.FIRST_HALF, None), + (LeaveType.FIRST_HALF, WorkFromHomeType.SECOND_HALF), + (None, WorkFromHomeType.FIRST_HALF), + # -- SH + (LeaveType.SECOND_HALF, None), + (LeaveType.SECOND_HALF, WorkFromHomeType.FIRST_HALF), + (None, WorkFromHomeType.SECOND_HALF), + ] + ) + user = models.ForeignKey(User, related_name="+", on_delete=models.CASCADE) date = models.DateField() leave_type = models.PositiveSmallIntegerField(null=True, blank=True, choices=LeaveType.choices) + wfh_type = models.PositiveSmallIntegerField(null=True, blank=True, choices=WorkFromHomeType.choices) journal_text = models.TextField(blank=True) @@ -28,3 +51,11 @@ class Meta: # type: ignore[reportIncompatibleVariableOverride] def __str__(self): return f"{self.user_id}#{self.date}" + + def clean(self): + super().clean() + + # Make sure leave_type and wfh_type don't conflict with each other + if self.leave_type is not None and self.wfh_type is not None: + if (self.leave_type, self.wfh_type) not in self.VALID_LEAVE_WFH_COMBINATION: + raise ValidationError(_("Provided Leave and Work from home combination is invalid")) diff --git a/apps/journal/serializers.py b/apps/journal/serializers.py index 385424f..b5f18cc 100644 --- a/apps/journal/serializers.py +++ b/apps/journal/serializers.py @@ -8,6 +8,7 @@ class Meta: # type: ignore[reportIncompatibleVariab] model = Journal fields = ( "leave_type", + "wfh_type", "journal_text", # TODO: Create custom serializer field to convert null -> empty string for black=True ) diff --git a/apps/journal/types.py b/apps/journal/types.py index 6b19b18..f4127ee 100644 --- a/apps/journal/types.py +++ b/apps/journal/types.py @@ -19,6 +19,8 @@ class JournalType: leave_type = enum_field(Journal.leave_type) leave_type_display = enum_display_field(Journal.leave_type) + wfh_type = enum_field(Journal.wfh_type) + wfh_type_display = enum_display_field(Journal.wfh_type) journal_text = string_field(Journal.journal_text) @staticmethod diff --git a/apps/project/admin.py b/apps/project/admin.py index f2eb544..b59e6d0 100644 --- a/apps/project/admin.py +++ b/apps/project/admin.py @@ -5,7 +5,7 @@ from apps.common.admin import PreventDeleteAdminMixin, UserResourceAdmin, VersionAdmin -from .models import Client, Contractor, Project +from .models import Client, Contractor, Deadline, Project @admin.register(Client) @@ -27,6 +27,20 @@ def get_queryset(self, request: HttpRequest) -> models.QuerySet[Client]: return super().get_queryset(request).select_related("created_by", "modified_by") +@admin.register(Deadline) +class DeadlineAdmin(PreventDeleteAdminMixin, VersionAdmin, UserResourceAdmin): + search_fields = ("name",) + + list_display = ("name", "created_by", "modified_by") + list_filter = ( + AutocompleteFilterFactory("Project", "project"), + AutocompleteFilterFactory("Contract", "contract"), + ) + + def get_queryset(self, request: HttpRequest) -> models.QuerySet[Client]: + return super().get_queryset(request).select_related("created_by", "modified_by") + + @admin.register(Project) class ProjectAdmin(PreventDeleteAdminMixin, VersionAdmin, UserResourceAdmin): search_fields = ("name",) diff --git a/apps/project/dataloaders.py b/apps/project/dataloaders.py index a537576..76b48d1 100644 --- a/apps/project/dataloaders.py +++ b/apps/project/dataloaders.py @@ -1,4 +1,5 @@ import typing +from collections import defaultdict from asgiref.sync import sync_to_async from django.utils.functional import cached_property @@ -6,10 +7,10 @@ from apps.common.dataloaders import load_model_objects -from .models import Client, Contractor, Project +from .models import Client, Contractor, Deadline, Project if typing.TYPE_CHECKING: - from .types import ClientType, ContractorType, ProjectType + from .types import ClientType, ContractorType, DeadlineType, ProjectType def load_client(keys: list[int]) -> list["ClientType"]: @@ -24,6 +25,14 @@ def load_project(keys: list[int]) -> list["ProjectType"]: return load_model_objects(Project, keys) # type: ignore[reportReturnType] +def load_deadlines(keys: list[int]) -> list[list["DeadlineType"]]: + qs = Deadline.objects.filter(project__in=keys) + _map = defaultdict(list) + for obj in qs: + _map[obj.project_id].append(obj) + return [_map.get(key, []) for key in keys] + + class ProjectDataLoader: @cached_property def load_client(self): @@ -36,3 +45,7 @@ def load_contractor(self): @cached_property def load_project(self): return DataLoader(load_fn=sync_to_async(load_project)) + + @cached_property + def load_deadlines(self): + return DataLoader(load_fn=sync_to_async(load_deadlines)) diff --git a/apps/project/migrations/0005_project_is_archived_deadline.py b/apps/project/migrations/0005_project_is_archived_deadline.py new file mode 100644 index 0000000..0e420de --- /dev/null +++ b/apps/project/migrations/0005_project_is_archived_deadline.py @@ -0,0 +1,70 @@ +# Generated by Django 4.2.15 on 2024-08-22 17:47 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("track", "0014_timeentry_duration_adjustment"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("project", "0004_project_logo"), + ] + + operations = [ + migrations.AddField( + model_name="project", + name="is_archived", + field=models.BooleanField(default=False), + ), + migrations.CreateModel( + name="Deadline", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("modified_at", models.DateTimeField(auto_now=True)), + ("name", models.CharField(max_length=225)), + ("is_archived", models.BooleanField(default=False)), + ("start_date", models.DateField()), + ("end_date", models.DateField()), + ( + "contract", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="+", + to="track.contract", + ), + ), + ( + "created_by", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="%(class)s_created", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "modified_by", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="%(class)s_modified", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "project", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, related_name="deadlines", to="project.project" + ), + ), + ], + options={ + "ordering": ["-id"], + "abstract": False, + }, + ), + ] diff --git a/apps/project/models.py b/apps/project/models.py index 7dcfd7f..35a2aca 100644 --- a/apps/project/models.py +++ b/apps/project/models.py @@ -31,9 +31,28 @@ class Project(UserResource): # This may collide in future. So, using `project_client` instead of just `client` project_client = models.ForeignKey(Client, on_delete=models.PROTECT, related_name="projects") contractor = models.ForeignKey(Contractor, on_delete=models.PROTECT, related_name="projects") + is_archived = models.BooleanField(default=False) project_client_id: int contractor_id: int def __str__(self): return self.name + + +class Deadline(UserResource): + name = models.CharField(max_length=225) + project = models.ForeignKey(Project, on_delete=models.PROTECT, related_name="deadlines") + contract = models.ForeignKey( + "track.Contract", + on_delete=models.PROTECT, + related_name="+", + null=True, + blank=True, + ) + + is_archived = models.BooleanField(default=False) + start_date = models.DateField() + end_date = models.DateField() + + project_id: int diff --git a/apps/project/queries.py b/apps/project/queries.py index 731555e..eb0d71d 100644 --- a/apps/project/queries.py +++ b/apps/project/queries.py @@ -6,7 +6,7 @@ from .filters import ClientFilter, ContractorFilter, ProjectFilter from .orders import ClientOrder, ContractorOrder, ProjectOrder -from .types import ClientType, ContractorType, ProjectType +from .types import ClientType, ContractorType, DeadlineType, ProjectType @strawberry.type @@ -30,6 +30,17 @@ class PrivateQuery: order=ProjectOrder, ) + # Unbound ---------------------------- + @strawberry_django.field + async def all_projects(self, info: Info) -> list[ProjectType]: + qs = ProjectType.get_queryset(None, None, info).filter(is_archived=False).all() + return [project async for project in qs] + + @strawberry_django.field + async def all_deadlines(self, info: Info) -> list[DeadlineType]: + qs = DeadlineType.get_queryset(None, None, info).filter(is_archived=False).all() + return [deadline async for deadline in qs] + # Single ---------------------------- @strawberry_django.field async def client(self, info: Info, pk: strawberry.ID) -> ClientType | None: diff --git a/apps/project/types.py b/apps/project/types.py index f5e28c5..6192744 100644 --- a/apps/project/types.py +++ b/apps/project/types.py @@ -1,13 +1,14 @@ import strawberry import strawberry_django from django.db import models +from django.utils import timezone from apps.common.types import UserResourceTypeMixin from main.graphql.context import Info from utils.common import get_queryset_for_model from utils.strawberry.types import string_field -from .models import Client, Contractor, Project +from .models import Client, Contractor, Deadline, Project @strawberry_django.type(Client) @@ -32,6 +33,36 @@ def get_queryset(_, queryset: models.QuerySet | None, info: Info): return get_queryset_for_model(Contractor, queryset) +@strawberry_django.type(Deadline) +class DeadlineType(UserResourceTypeMixin): + id: strawberry.ID + start_date: strawberry.auto + end_date: strawberry.auto + + name = string_field(Deadline.name) + project_id: strawberry.ID + contract_id: strawberry.ID | None + + @staticmethod + def get_queryset(_, queryset: models.QuerySet | None, info: Info): + return get_queryset_for_model(Deadline, queryset) + + @strawberry_django.field + async def total_days(self, root: strawberry.Parent[Deadline]) -> int: + # TODO: Return only working days + return (root.end_date - root.start_date).days + + @strawberry_django.field + async def used_days(self, root: strawberry.Parent[Deadline]) -> int: + # TODO: Return only working days + return (timezone.now().date() - root.start_date).days + + @strawberry_django.field + async def remaining_days(self, root: strawberry.Parent[Deadline]) -> int: + # TODO: Return only working days + return (root.end_date - timezone.now().date()).days + + @strawberry_django.type(Project) class ProjectType(UserResourceTypeMixin): id: strawberry.ID @@ -47,9 +78,13 @@ def get_queryset(_, queryset: models.QuerySet | None, info: Info): return get_queryset_for_model(Project, queryset) @strawberry_django.field - async def project_client(self, root: Project, info: Info) -> ClientType: + async def project_client(self, root: strawberry.Parent[Project], info: Info) -> ClientType: return await info.context.dl.project.load_client.load(root.project_client_id) @strawberry_django.field - async def contractor(self, root: Project, info: Info) -> ContractorType: + async def contractor(self, root: strawberry.Parent[Project], info: Info) -> ContractorType: return await info.context.dl.project.load_contractor.load(root.contractor_id) + + @strawberry_django.field + async def deadlines(self, root: strawberry.Parent[Project], info: Info) -> list[DeadlineType]: + return await info.context.dl.project.load_deadlines.load(root.id) diff --git a/apps/standup/admin.py b/apps/standup/admin.py new file mode 100644 index 0000000..b63772b --- /dev/null +++ b/apps/standup/admin.py @@ -0,0 +1,16 @@ +from django.contrib import admin +from django.db import models +from django.http import HttpRequest + +from apps.common.admin import PreventDeleteAdminMixin, UserResourceAdmin, VersionAdmin + +from .models import Quote + + +@admin.register(Quote) +class QuoteAdmin(PreventDeleteAdminMixin, VersionAdmin, UserResourceAdmin): + search_fields = ("author",) + list_display = ("author", "created_by", "modified_by") + + def get_queryset(self, request: HttpRequest) -> models.QuerySet[Quote]: + return super().get_queryset(request).select_related("created_by", "modified_by") diff --git a/apps/standup/migrations/0001_initial.py b/apps/standup/migrations/0001_initial.py new file mode 100644 index 0000000..addb05c --- /dev/null +++ b/apps/standup/migrations/0001_initial.py @@ -0,0 +1,47 @@ +# Generated by Django 4.2.15 on 2024-08-22 17:53 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="Quote", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("modified_at", models.DateTimeField(auto_now=True)), + ("text", models.TextField()), + ("author", models.CharField(max_length=225)), + ( + "created_by", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="%(class)s_created", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "modified_by", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="%(class)s_modified", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "ordering": ["-id"], + "abstract": False, + }, + ), + ] diff --git a/apps/standup/migrations/__init__.py b/apps/standup/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/standup/models.py b/apps/standup/models.py index f8d2575..6755fa3 100644 --- a/apps/standup/models.py +++ b/apps/standup/models.py @@ -1,4 +1,6 @@ -# from django.db import models +from django.db import models + +from apps.common.models import UserResource # from apps.user.models import User @@ -9,3 +11,8 @@ # slack_thread_id = models.CharField(max_length=200) # TODO: Check length # text = models.TextField() # TODO: Do we need this? + + +class Quote(UserResource): + text = models.TextField() + author = models.CharField(max_length=225) diff --git a/apps/standup/queries.py b/apps/standup/queries.py new file mode 100644 index 0000000..5c3f2cc --- /dev/null +++ b/apps/standup/queries.py @@ -0,0 +1,14 @@ +import datetime + +import strawberry +import strawberry_django + +from .types import DailyStandUpType + + +@strawberry.type +class PrivateQuery: + # Single ---------------------------- + @strawberry_django.field + async def daily_standup(self, date: datetime.date) -> DailyStandUpType: + return DailyStandUpType(date=date) diff --git a/apps/standup/types.py b/apps/standup/types.py new file mode 100644 index 0000000..2f85ea4 --- /dev/null +++ b/apps/standup/types.py @@ -0,0 +1,102 @@ +import datetime + +import strawberry +import strawberry_django +from django.db import models +from django.utils import timezone + +from apps.common.types import UserResourceTypeMixin +from apps.journal.enums import JournalLeaveTypeEnum, JournalWorkFromHomeTypeEnum +from apps.project.models import Project +from apps.project.types import ProjectType +from apps.standup.models import Quote +from apps.track.models import TimeEntry +from apps.user.models import User +from main.graphql.context import Info +from utils.common import get_queryset_for_model +from utils.strawberry.types import string_field + + +@strawberry_django.type(Quote) +class QuoteType(UserResourceTypeMixin): + id: strawberry.ID + + text = string_field(Quote.text) + author = string_field(Quote.author) + + @staticmethod + def get_queryset(_, queryset: models.QuerySet | None, info: Info): + return get_queryset_for_model(Quote, queryset) + + +@strawberry.type +class DailyStandUpProjectStatUserType: + user_obj: strawberry.Private[User] + date: strawberry.Private[datetime.date] + + @strawberry.field + def id(self) -> strawberry.ID: + return strawberry.ID(str(self.user_obj.pk)) + + @strawberry.field + def display_picture(self) -> str | None: + return self.user_obj.display_picture + + @strawberry.field + def display_name(self) -> str: + return self.user_obj.display_name + + @strawberry.field + async def leave(self, info: Info) -> JournalLeaveTypeEnum | None: # type: ignore[reportInvalidTypeForm] + return await info.context.dl.journal.load_user_leave.load((self.user_obj.pk, self.date)) + + @strawberry.field + async def work_from_home(self, info: Info) -> JournalWorkFromHomeTypeEnum | None: # type: ignore[reportInvalidTypeForm] + return await info.context.dl.journal.load_user_work_from_home.load((self.user_obj.pk, self.date)) + + +@strawberry.type +class DailyStandUpProjectStatType: + project_obj: strawberry.Private[Project] + date: strawberry.Private[datetime.date] + + @strawberry.field + def project(self) -> ProjectType: + return self.project_obj # type: ignore[reportReturnType] + + @strawberry.field + async def users(self) -> list[DailyStandUpProjectStatUserType]: + threshold = timezone.now() - datetime.timedelta(days=3) + time_entries_qs = ( + TimeEntry.objects.filter( + task__contract__project=self.project_obj, + date__gte=threshold, + ) + .values("user") + .distinct() + ) + return [ + DailyStandUpProjectStatUserType(user_obj=user, date=self.date) + async for user in User.objects.filter(id__in=time_entries_qs).all() + ] + + +@strawberry.type +class DailyStandUpType: + date: strawberry.Private[datetime.date] + + def id(self) -> strawberry.ID: + return strawberry.ID(self.date.isoformat()) + + # TODO: primary_conductor + # TODO: secondary_conductor + + @strawberry.field + async def quote(self, info: Info) -> QuoteType | None: + return await QuoteType.get_queryset(None, None, info).order_by("?").afirst() + + @strawberry.field + async def project_stat(self, info: Info, pk: strawberry.ID) -> DailyStandUpProjectStatType | None: + project = await ProjectType.get_queryset(None, None, info).filter(pk=pk).afirst() + if project: + return DailyStandUpProjectStatType(project_obj=project, date=self.date) diff --git a/apps/track/models.py b/apps/track/models.py index 0a36855..4e4759d 100644 --- a/apps/track/models.py +++ b/apps/track/models.py @@ -7,6 +7,7 @@ from apps.user.models import User +# TODO: Move this to project? class Contract(UserResource): name = models.CharField(max_length=225) description = models.TextField(blank=True) @@ -22,6 +23,7 @@ def __str__(self): return f"{self.project.name} -> {self.name} ({self.total_estimated_hours} hours)" +# TODO: Move this to project? class Task(UserResource): name = models.CharField(max_length=225) contract = models.ForeignKey(Contract, on_delete=models.PROTECT, related_name="tasks") diff --git a/main/graphql/dataloaders.py b/main/graphql/dataloaders.py index 324182b..27f94bf 100644 --- a/main/graphql/dataloaders.py +++ b/main/graphql/dataloaders.py @@ -1,5 +1,6 @@ from django.utils.functional import cached_property +from apps.journal.dataloaders import JournalDataLoader from apps.project.dataloaders import ProjectDataLoader from apps.track.dataloaders import TrackDataLoader from apps.user.dataloaders import UserDataLoader @@ -19,3 +20,7 @@ def track(self): @cached_property def project(self): return ProjectDataLoader() + + @cached_property + def journal(self): + return JournalDataLoader() diff --git a/main/graphql/schema.py b/main/graphql/schema.py index e4076ce..9ca32c1 100644 --- a/main/graphql/schema.py +++ b/main/graphql/schema.py @@ -6,6 +6,7 @@ from apps.journal import mutations as journal_mutations from apps.journal import queries as journal_queries from apps.project import queries as project_queries +from apps.standup import queries as standup_queries from apps.track import mutations as track_mutations from apps.track import queries as track_queries from apps.user import mutations as user_mutations @@ -36,6 +37,7 @@ class PublicQuery( @strawberry.type class PrivateQuery( user_queries.PrivateQuery, + standup_queries.PrivateQuery, project_queries.PrivateQuery, track_queries.PrivateQuery, journal_queries.PrivateQuery, diff --git a/schema.graphql b/schema.graphql index f8f0582..afc7fa2 100644 --- a/schema.graphql +++ b/schema.graphql @@ -2,6 +2,7 @@ type AppEnumCollection { TimeEntryType: [AppEnumCollectionTimeEntryType!]! TimeEntryStatus: [AppEnumCollectionTimeEntryStatus!]! JournalLeaveType: [AppEnumCollectionJournalLeaveType!]! + JournalWfhType: [AppEnumCollectionJournalWfhType!]! } type AppEnumCollectionJournalLeaveType { @@ -9,6 +10,11 @@ type AppEnumCollectionJournalLeaveType { label: String! } +type AppEnumCollectionJournalWfhType { + key: JournalWorkFromHomeTypeEnum! + label: String! +} + type AppEnumCollectionTimeEntryStatus { key: TimeEntryStatusEnum! label: String! @@ -139,6 +145,24 @@ type ContractorTypeCountList { """A generic type to return error messages""" scalar CustomErrorType +type DailyStandUpProjectStatType { + project: ProjectType! + users: [DailyStandUpProjectStatUserType!]! +} + +type DailyStandUpProjectStatUserType { + id: ID! + displayPicture: String + displayName: String! + leave: JournalLeaveTypeEnum + workFromHome: JournalWorkFromHomeTypeEnum +} + +type DailyStandUpType { + quote: QuoteType + projectStat(pk: ID!): DailyStandUpProjectStatType +} + """Date (isoformat)""" scalar Date @@ -186,6 +210,22 @@ input DateRangeLookup { """Date with time (isoformat)""" scalar DateTime +type DeadlineType implements UserResourceTypeMixin { + createdAt: DateTime! + modifiedAt: DateTime! + createdBy: UserType! + modifiedBy: UserType! + id: ID! + startDate: Date! + endDate: Date! + projectId: ID! + contractId: ID + name: String! + totalDays: Int! + usedDays: Int! + remainingDays: Int! +} + type DjangoImageType { name: String! size: Int! @@ -256,6 +296,8 @@ type JournalType { date: Date! leaveType: JournalLeaveTypeEnum leaveTypeDisplay: String + wfhType: JournalWorkFromHomeTypeEnum + wfhTypeDisplay: String journalText: String user: UserType! } @@ -268,9 +310,16 @@ type JournalTypeMutationResponseType { input JournalUpdateInput { leaveType: JournalLeaveTypeEnum + wfhType: JournalWorkFromHomeTypeEnum journalText: String } +enum JournalWorkFromHomeTypeEnum { + FULL + FIRST_HALF + SECOND_HALF +} + input LoginInput { email: String! password: String! @@ -309,9 +358,12 @@ type PrivateMutation { } type PrivateQuery { + dailyStandup(date: Date!): DailyStandUpType! clients(filters: ClientFilter, order: ClientOrder, pagination: OffsetPaginationInput): ClientTypeCountList! contractors(filters: ContractorFilter, order: ContractorOrder, pagination: OffsetPaginationInput): ContractorTypeCountList! projects(filters: ProjectFilter, order: ProjectOrder, pagination: OffsetPaginationInput): ProjectTypeCountList! + allProjects: [ProjectType!]! + allDeadlines: [DeadlineType!]! client(pk: ID!): ClientType contractor(pk: ID!): ContractorType project(pk: ID!): ProjectType @@ -359,6 +411,7 @@ type ProjectType implements UserResourceTypeMixin { description: String projectClient: ClientType! contractor: ContractorType! + deadlines: [DeadlineType!]! } type ProjectTypeCountList { @@ -385,6 +438,16 @@ type Query { enums: AppEnumCollection! } +type QuoteType implements UserResourceTypeMixin { + createdAt: DateTime! + modifiedAt: DateTime! + createdBy: UserType! + modifiedBy: UserType! + id: ID! + text: String! + author: String! +} + input StrFilterLookup { """Exact match. Filter will be skipped on `null` value""" exact: String diff --git a/utils/strawberry/types.py b/utils/strawberry/types.py index d0fc2f0..2797da3 100644 --- a/utils/strawberry/types.py +++ b/utils/strawberry/types.py @@ -96,7 +96,7 @@ class DjangoFileType: @strawberry.field @staticmethod def url(root: files.FieldFile, info: Info) -> str: - # TODO: Use cache if using S3 with signatured URL + # TODO: Use cache if using S3 URL with signature if isinstance(default_storage, FileSystemStorage): return info.context.request.build_absolute_uri(root.url) return root.url From 3aa1f257d196b8a4c8d6bac3f3d020d5f198a232 Mon Sep 17 00:00:00 2001 From: thenav56 Date: Fri, 23 Aug 2024 13:41:38 +0545 Subject: [PATCH 30/51] Add events - Make time track type nullable --- apps/common/admin.py | 30 ++++++- apps/common/enums.py | 10 +++ apps/common/filters.py | 13 ++++ apps/common/migrations/0001_initial.py | 33 +++++++- apps/common/models.py | 78 ++++++++++++++++++- apps/common/orders.py | 12 +++ apps/common/queries.py | 25 ++++++ apps/common/types.py | 21 ++++- apps/journal/models.py | 9 ++- apps/journal/serializers.py | 5 ++ apps/project/admin.py | 22 +----- apps/standup/admin.py | 7 +- apps/standup/types.py | 19 ++++- apps/track/admin.py | 37 ++++++++- .../migrations/0015_timeentry_is_billable.py | 18 +++++ .../migrations/0016_alter_timeentry_type.py | 32 ++++++++ apps/track/models.py | 5 +- main/graphql/enums.py | 2 + main/graphql/schema.py | 2 + schema.graphql | 63 ++++++++++++++- 20 files changed, 400 insertions(+), 43 deletions(-) create mode 100644 apps/common/enums.py create mode 100644 apps/common/filters.py create mode 100644 apps/common/orders.py create mode 100644 apps/common/queries.py create mode 100644 apps/track/migrations/0015_timeentry_is_billable.py create mode 100644 apps/track/migrations/0016_alter_timeentry_type.py diff --git a/apps/common/admin.py b/apps/common/admin.py index 5c1c23f..884d9b2 100644 --- a/apps/common/admin.py +++ b/apps/common/admin.py @@ -1,9 +1,16 @@ +import typing + from django.contrib import admin +from django.db import models +from django.http import HttpRequest from reversion.admin import VersionAdmin as OgVersionAdmin -from .models import UserResource +from .models import Event, UserResource + +DjangoModel = typing.TypeVar("DjangoModel", bound=models.Model) +# -- Abstracts class VersionAdmin(OgVersionAdmin): history_latest_first = True @@ -14,6 +21,16 @@ def has_delete_permission(self, request, obj=None): class UserResourceAdmin(admin.ModelAdmin): + def get_list_display(self, request): + list_display = super().get_list_display(request) + for field in ["created_by", "modified_by"]: + if field not in list_display: + list_display = [ + *list_display, + field, + ] + return list_display + def get_readonly_fields(self, *args, **kwargs): readonly_fields = super().get_readonly_fields(*args, **kwargs) # type: ignore[reportAttributeAccessIssue] return [ @@ -49,6 +66,9 @@ def save_formset(self, request, form, formset, change): instance.modified_by = request.user instance.save() + def get_queryset(self, request: HttpRequest) -> models.QuerySet[DjangoModel]: + return super().get_queryset(request).select_related("created_by", "modified_by") + class UserResourceTabularInline(admin.TabularInline): def get_readonly_fields(self, *args, **kwargs): @@ -65,3 +85,11 @@ def get_readonly_fields(self, *args, **kwargs): ] ) ] + + +# -- Common Models +@admin.register(Event) +class ClientAdmin(VersionAdmin, UserResourceAdmin): + search_fields = ("name",) + list_display = ("name", "type", "start_date", "end_date") + list_filter = ("type",) diff --git a/apps/common/enums.py b/apps/common/enums.py new file mode 100644 index 0000000..b257c5f --- /dev/null +++ b/apps/common/enums.py @@ -0,0 +1,10 @@ +import strawberry + +from utils.strawberry.enums import get_enum_name_from_django_field + +from .models import Event + +EventTypeEnum = strawberry.enum(Event.Type, name="EventTypeEnum") + + +enum_map = {get_enum_name_from_django_field(field): enum for field, enum in ((Event.type, EventTypeEnum),)} diff --git a/apps/common/filters.py b/apps/common/filters.py new file mode 100644 index 0000000..d37f9b1 --- /dev/null +++ b/apps/common/filters.py @@ -0,0 +1,13 @@ +import strawberry +import strawberry_django + +from .enums import EventTypeEnum +from .models import Event + + +@strawberry_django.filters.filter(Event, lookups=True) +class EventFilter: + id: strawberry.auto + start_date: strawberry.auto + end_date: strawberry.auto + types: list[EventTypeEnum] # type: ignore[reportInvalidTypeForm] diff --git a/apps/common/migrations/0001_initial.py b/apps/common/migrations/0001_initial.py index 9d6048c..e887a47 100644 --- a/apps/common/migrations/0001_initial.py +++ b/apps/common/migrations/0001_initial.py @@ -1,5 +1,7 @@ -# Generated by Django 4.2.15 on 2024-08-22 17:47 +# Generated by Django 4.2.15 on 2024-08-23 04:46 +import django.db.models.deletion +from django.conf import settings from django.db import migrations, models @@ -7,16 +9,41 @@ class Migration(migrations.Migration): initial = True - dependencies = [] + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] operations = [ migrations.CreateModel( name="Event", fields=[ ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("modified_at", models.DateTimeField(auto_now=True)), ("name", models.CharField(max_length=225)), - ("date", models.DateField()), ("type", models.PositiveSmallIntegerField(choices=[(1, "Holiday"), (2, "Retreat"), (3, "Misc")], default=1)), + ("start_date", models.DateField()), + ("end_date", models.DateField()), + ( + "created_by", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="%(class)s_created", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "modified_by", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="%(class)s_modified", + to=settings.AUTH_USER_MODEL, + ), + ), ], + options={ + "ordering": ["-id"], + "abstract": False, + }, ), ] diff --git a/apps/common/models.py b/apps/common/models.py index e81523d..ae70976 100644 --- a/apps/common/models.py +++ b/apps/common/models.py @@ -1,8 +1,13 @@ +import datetime +import functools + from django.db import models +from django.utils import timezone from apps.user.models import User +# -- Abstracts class UserResource(models.Model): created_at = models.DateTimeField(auto_now_add=True) modified_at = models.DateTimeField(auto_now=True) @@ -28,12 +33,81 @@ class Meta: # type: ignore[reportIncompatibleVariableOverride] ordering = ["-id"] -class Event(models.Model): +# -- Common models +class Event(UserResource): class Type(models.IntegerChoices): HOLIDAY = 1, "Holiday" RETREAT = 2, "Retreat" MISC = 3, "Misc" name = models.CharField(max_length=225) - date = models.DateField() type = models.PositiveSmallIntegerField(choices=Type.choices, default=Type.HOLIDAY) + + start_date = models.DateField() + end_date = models.DateField() + + @staticmethod + def is_weekend(date: datetime.date): + return date.weekday() > 4 # 5 Sat, 6 Su + + def get_dates(self, include_weekends=False) -> list[datetime.date]: + if self.start_date == self.end_date: + if not include_weekends and self.is_weekend(self.start_date): + return [] + return [self.start_date] + + dates = [] + for x in range((self.end_date - self.start_date).days): + date = self.start_date + datetime.timedelta(days=x) + if not include_weekends and self.is_weekend(date): + continue + dates.append(date) + return sorted(set(dates)) + + @classmethod + def get_last_working_date( + cls, + now_date: datetime.date, + offset_count: int | None = None, + ) -> datetime.date: # type: ignore[reportReturnType] + # TODO: Add test + event_dates = set(cls.get_relative_event_dates()) + found_count = 0 + for x in range(30): # Create a 1 month window, Should be enough + date = now_date - datetime.timedelta(days=x) + if cls.is_weekend(date) or date in event_dates: + continue + if offset_count is not None and found_count < offset_count: + found_count += 1 + continue + return date + + @classmethod + def get_relative_events(cls) -> models.QuerySet["Event"]: + """ + Return list of dates with holiday relative to current date + """ + # TODO: Add test + now = timezone.now().date() + start_threshold = now - datetime.timedelta(days=200) + end_threshold = now + datetime.timedelta(days=200) + return cls.objects.filter(start_date__gte=start_threshold, end_date__lte=end_threshold) + + @classmethod + @functools.cache + def get_relative_event_dates(cls) -> list[datetime.date]: + """ + Return list of dates with holiday relative to current date + """ + dates = [] + + qs = cls.get_relative_events() + for start_date, end_date in qs.values_list("start_date", "end_date"): + if start_date == end_date: + dates.append(start_date) + continue + + for x in range((end_date - start_date).days): + dates.append(start_date + datetime.timedelta(days=x)) + + return sorted(set(dates)) diff --git a/apps/common/orders.py b/apps/common/orders.py new file mode 100644 index 0000000..3ce97bc --- /dev/null +++ b/apps/common/orders.py @@ -0,0 +1,12 @@ +import strawberry +import strawberry_django + +from .models import Event + + +@strawberry_django.ordering.order(Event) +class EventOrder: + id: strawberry.auto + name: strawberry.auto + start_date: strawberry.auto + end_date: strawberry.auto diff --git a/apps/common/queries.py b/apps/common/queries.py new file mode 100644 index 0000000..b898f61 --- /dev/null +++ b/apps/common/queries.py @@ -0,0 +1,25 @@ +import strawberry +import strawberry_django + +from main.graphql.context import Info +from utils.strawberry.paginations import CountList, pagination_field + +from .filters import EventFilter +from .models import Event +from .orders import EventOrder +from .types import EventType + + +@strawberry.type +class PrivateQuery: + # Paginated ---------------------------- + events: CountList[EventType] = pagination_field( + pagination=True, + filters=EventFilter, + order=EventOrder, + ) + + # Unbound ---------------------------- + @strawberry_django.field + async def relative_events(self, info: Info) -> list[EventType]: + return [event async for event in Event.get_relative_events()] # type: ignore[reportReturnType] diff --git a/apps/common/types.py b/apps/common/types.py index 7d43fef..cb9c154 100644 --- a/apps/common/types.py +++ b/apps/common/types.py @@ -8,10 +8,13 @@ from apps.user.types import UserType from main.caches import local_cache from main.graphql.context import Info +from utils.strawberry.enums import enum_display_field, enum_field +from utils.strawberry.types import string_field -from .models import UserResource +from .models import Event, UserResource +# -- Interfaces @strawberry.interface class UserResourceTypeMixin: created_at: datetime.datetime @@ -37,3 +40,19 @@ def client_id(self, root: models.Model, info: Info) -> strawberry.ID: or local_cache.get(TempClientIdMixin.get_cache_key(self, info.context.request)) or str(root.pk) ) + + +# -- Common models type +@strawberry_django.type(Event) +class EventType(UserResourceTypeMixin): + id: strawberry.ID + start_date: strawberry.auto + end_date: strawberry.auto + name = string_field(Event.name) + + type = enum_field(Event.type) + type_display = enum_display_field(Event.type) + + @strawberry_django.field + def dates(self, event: strawberry.Parent[Event]) -> list[datetime.date]: + return event.get_dates() diff --git a/apps/journal/models.py b/apps/journal/models.py index 6b666a4..64396fa 100644 --- a/apps/journal/models.py +++ b/apps/journal/models.py @@ -52,10 +52,13 @@ class Meta: # type: ignore[reportIncompatibleVariableOverride] def __str__(self): return f"{self.user_id}#{self.date}" - def clean(self): - super().clean() - + def leave_wfh_check(self): # Make sure leave_type and wfh_type don't conflict with each other if self.leave_type is not None and self.wfh_type is not None: if (self.leave_type, self.wfh_type) not in self.VALID_LEAVE_WFH_COMBINATION: raise ValidationError(_("Provided Leave and Work from home combination is invalid")) + pass + + def clean(self): + super().clean() + self.leave_wfh_check() diff --git a/apps/journal/serializers.py b/apps/journal/serializers.py index b5f18cc..3ae9110 100644 --- a/apps/journal/serializers.py +++ b/apps/journal/serializers.py @@ -12,6 +12,11 @@ class Meta: # type: ignore[reportIncompatibleVariab] "journal_text", # TODO: Create custom serializer field to convert null -> empty string for black=True ) + def validate(self, attrs): + super().validate(attrs) + Journal(**attrs).leave_wfh_check() # Check leave validation + return attrs + def create(self, validated_data): validated_data["user"] = self.context["request"].user validated_data["date"] = self.context["extra_context"]["journal_date"] diff --git a/apps/project/admin.py b/apps/project/admin.py index b59e6d0..b936107 100644 --- a/apps/project/admin.py +++ b/apps/project/admin.py @@ -1,7 +1,5 @@ from admin_auto_filters.filters import AutocompleteFilterFactory from django.contrib import admin -from django.db import models -from django.http import HttpRequest from apps.common.admin import PreventDeleteAdminMixin, UserResourceAdmin, VersionAdmin @@ -11,35 +9,26 @@ @admin.register(Client) class ClientAdmin(PreventDeleteAdminMixin, VersionAdmin, UserResourceAdmin): search_fields = ("name",) - list_display = ("name", "created_by", "modified_by") - - def get_queryset(self, request: HttpRequest) -> models.QuerySet[Client]: - return super().get_queryset(request).select_related("created_by", "modified_by") + list_display = ("name",) @admin.register(Contractor) class ContractorAdmin(PreventDeleteAdminMixin, VersionAdmin, UserResourceAdmin): search_fields = ("name",) - list_display = ("name", "created_by", "modified_by") - - def get_queryset(self, request: HttpRequest) -> models.QuerySet[Client]: - return super().get_queryset(request).select_related("created_by", "modified_by") + list_display = ("name",) @admin.register(Deadline) class DeadlineAdmin(PreventDeleteAdminMixin, VersionAdmin, UserResourceAdmin): search_fields = ("name",) - list_display = ("name", "created_by", "modified_by") + list_display = ("name",) list_filter = ( AutocompleteFilterFactory("Project", "project"), AutocompleteFilterFactory("Contract", "contract"), ) - def get_queryset(self, request: HttpRequest) -> models.QuerySet[Client]: - return super().get_queryset(request).select_related("created_by", "modified_by") - @admin.register(Project) class ProjectAdmin(PreventDeleteAdminMixin, VersionAdmin, UserResourceAdmin): @@ -49,7 +38,4 @@ class ProjectAdmin(PreventDeleteAdminMixin, VersionAdmin, UserResourceAdmin): AutocompleteFilterFactory("Contractor", "contractor"), ) - list_display = ("name", "created_by", "modified_by") - - def get_queryset(self, request: HttpRequest) -> models.QuerySet[Client]: - return super().get_queryset(request).select_related("created_by", "modified_by") + list_display = ("name",) diff --git a/apps/standup/admin.py b/apps/standup/admin.py index b63772b..d0e3eee 100644 --- a/apps/standup/admin.py +++ b/apps/standup/admin.py @@ -1,6 +1,4 @@ from django.contrib import admin -from django.db import models -from django.http import HttpRequest from apps.common.admin import PreventDeleteAdminMixin, UserResourceAdmin, VersionAdmin @@ -10,7 +8,4 @@ @admin.register(Quote) class QuoteAdmin(PreventDeleteAdminMixin, VersionAdmin, UserResourceAdmin): search_fields = ("author",) - list_display = ("author", "created_by", "modified_by") - - def get_queryset(self, request: HttpRequest) -> models.QuerySet[Quote]: - return super().get_queryset(request).select_related("created_by", "modified_by") + list_display = ("author",) diff --git a/apps/standup/types.py b/apps/standup/types.py index 2f85ea4..9bbb66c 100644 --- a/apps/standup/types.py +++ b/apps/standup/types.py @@ -2,9 +2,10 @@ import strawberry import strawberry_django +from asgiref.sync import sync_to_async from django.db import models -from django.utils import timezone +from apps.common.models import Event from apps.common.types import UserResourceTypeMixin from apps.journal.enums import JournalLeaveTypeEnum, JournalWorkFromHomeTypeEnum from apps.project.models import Project @@ -60,17 +61,29 @@ class DailyStandUpProjectStatType: project_obj: strawberry.Private[Project] date: strawberry.Private[datetime.date] + async def _check_activity_from_date(self) -> datetime.date: + return await sync_to_async(Event.get_last_working_date)(now_date=self.date, offset_count=3) + + @strawberry.field + async def last_working_date(self) -> datetime.date: + return await sync_to_async(Event.get_last_working_date)(now_date=self.date) + + # XXX: For debugging only + @strawberry.field + async def activity_from_date(self) -> datetime.date: + return await self._check_activity_from_date() + @strawberry.field def project(self) -> ProjectType: return self.project_obj # type: ignore[reportReturnType] @strawberry.field async def users(self) -> list[DailyStandUpProjectStatUserType]: - threshold = timezone.now() - datetime.timedelta(days=3) + last_working_date = await self._check_activity_from_date() time_entries_qs = ( TimeEntry.objects.filter( task__contract__project=self.project_obj, - date__gte=threshold, + date__gte=last_working_date, ) .values("user") .distinct() diff --git a/apps/track/admin.py b/apps/track/admin.py index 5124ec1..4eb56a5 100644 --- a/apps/track/admin.py +++ b/apps/track/admin.py @@ -1,7 +1,8 @@ from admin_auto_filters.filters import AutocompleteFilterFactory -from django.contrib import admin +from django.contrib import admin, messages from django.db import models from django.http import HttpRequest +from django.utils.translation import ngettext from apps.common.admin import ( PreventDeleteAdminMixin, @@ -66,12 +67,44 @@ def get_contract(self, obj): return obj.contract.name +# Time Entry ---------------------------------------------------- +@admin.action(description="Mark time entries as non-billable") +def flag_as_non_billable(modeladmin, request, queryset): + updated = queryset.update(is_billable=False) + modeladmin.message_user( + request, + ngettext( + "%d time entry was successfully marked as non-billable.", + "%d time entries were successfully marked as non-billable.", + updated, + ) + % updated, + messages.SUCCESS, + ) + + +@admin.action(description="Mark time entries as billable") +def flag_as_billable(modeladmin, request, queryset): + updated = queryset.update(is_billable=True) + modeladmin.message_user( + request, + ngettext( + "%d time entry was successfully marked as billable.", + "%d time entries were successfully marked as billable.", + updated, + ) + % updated, + messages.SUCCESS, + ) + + @admin.register(TimeEntry) class TimeEntryAdmin(admin.ModelAdmin): list_filter = ( "date", "type", "status", + "is_billable", AutocompleteFilterFactory("Project", "task__contract__project"), AutocompleteFilterFactory("Contract", "task__contract"), AutocompleteFilterFactory("Task", "task"), @@ -90,8 +123,10 @@ class TimeEntryAdmin(admin.ModelAdmin): "date", "duration", "duration_adjustment", + "is_billable", "status", ) + actions = [flag_as_non_billable, flag_as_billable] def get_queryset(self, request: HttpRequest) -> models.QuerySet[Contract]: return super().get_queryset(request).select_related("user", "task", "task__contract", "task__contract__project") diff --git a/apps/track/migrations/0015_timeentry_is_billable.py b/apps/track/migrations/0015_timeentry_is_billable.py new file mode 100644 index 0000000..285705a --- /dev/null +++ b/apps/track/migrations/0015_timeentry_is_billable.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.15 on 2024-08-23 07:09 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("track", "0014_timeentry_duration_adjustment"), + ] + + operations = [ + migrations.AddField( + model_name="timeentry", + name="is_billable", + field=models.BooleanField(default=True), + ), + ] diff --git a/apps/track/migrations/0016_alter_timeentry_type.py b/apps/track/migrations/0016_alter_timeentry_type.py new file mode 100644 index 0000000..cc7ba6e --- /dev/null +++ b/apps/track/migrations/0016_alter_timeentry_type.py @@ -0,0 +1,32 @@ +# Generated by Django 4.2.15 on 2024-08-23 07:55 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("track", "0015_timeentry_is_billable"), + ] + + operations = [ + migrations.AlterField( + model_name="timeentry", + name="type", + field=models.PositiveSmallIntegerField( + blank=True, + choices=[ + (1000, "Design"), + (1100, "Development"), + (1200, "DevOps"), + (2000, "Documentation"), + (3000, "Internal Discussion"), + (3100, "External Discussion"), + (4000, "Meeting"), + (5000, "Project Management"), + (6000, "QA"), + ], + null=True, + ), + ), + ] diff --git a/apps/track/models.py b/apps/track/models.py index 4e4759d..0b2dc26 100644 --- a/apps/track/models.py +++ b/apps/track/models.py @@ -59,7 +59,7 @@ class Status(models.IntegerChoices): date = models.DateField() created_at = models.DateTimeField(auto_now_add=True) # To track TODO tasks - type = models.PositiveSmallIntegerField(choices=Type.choices) + type = models.PositiveSmallIntegerField(choices=Type.choices, null=True, blank=True) status = models.PositiveSmallIntegerField(choices=Status.choices) # NOTE: client_id persisted as ULID, but no validation done on server-side # Uniqueness is required at per-user per-day level @@ -74,6 +74,8 @@ class Status(models.IntegerChoices): blank=True, help_text=_("Minutes"), ) + + # Operational metadata duration_adjustment = models.SmallIntegerField( null=True, blank=True, @@ -81,6 +83,7 @@ class Status(models.IntegerChoices): "Minutes. Used to keep track of reported minutes. This will be used as duration (+- duration_adjustment)" ), ) + is_billable = models.BooleanField(default=True) user_id: int task_id: int diff --git a/main/graphql/enums.py b/main/graphql/enums.py index 1c2fe7e..ce3857f 100644 --- a/main/graphql/enums.py +++ b/main/graphql/enums.py @@ -2,11 +2,13 @@ import strawberry +from apps.common.enums import enum_map as common_enum_map from apps.journal.enums import enum_map as journal_enum_map from apps.track.enums import enum_map as track_enum_map from apps.user.enums import enum_map as user_enum_map ENUM_TO_STRAWBERRY_ENUM_MAP: dict[str, type] = { + **common_enum_map, **user_enum_map, **track_enum_map, **journal_enum_map, diff --git a/main/graphql/schema.py b/main/graphql/schema.py index 9ca32c1..787b3c2 100644 --- a/main/graphql/schema.py +++ b/main/graphql/schema.py @@ -3,6 +3,7 @@ # Imported to make sure strawberry custom modules are loadded first import utils.strawberry.transformers # pyright: ignore[reportUnusedImport] # type: ignore # noqa F401 +from apps.common import queries as common_queries from apps.journal import mutations as journal_mutations from apps.journal import queries as journal_queries from apps.project import queries as project_queries @@ -41,6 +42,7 @@ class PrivateQuery( project_queries.PrivateQuery, track_queries.PrivateQuery, journal_queries.PrivateQuery, + common_queries.PrivateQuery, ): id: strawberry.ID = strawberry.ID("private") diff --git a/schema.graphql b/schema.graphql index afc7fa2..da29864 100644 --- a/schema.graphql +++ b/schema.graphql @@ -1,10 +1,16 @@ type AppEnumCollection { + EventType: [AppEnumCollectionEventType!]! TimeEntryType: [AppEnumCollectionTimeEntryType!]! TimeEntryStatus: [AppEnumCollectionTimeEntryStatus!]! JournalLeaveType: [AppEnumCollectionJournalLeaveType!]! JournalWfhType: [AppEnumCollectionJournalWfhType!]! } +type AppEnumCollectionEventType { + key: EventTypeEnum! + label: String! +} + type AppEnumCollectionJournalLeaveType { key: JournalLeaveTypeEnum! label: String! @@ -146,6 +152,8 @@ type ContractorTypeCountList { scalar CustomErrorType type DailyStandUpProjectStatType { + lastWorkingDate: Date! + activityFromDate: Date! project: ProjectType! users: [DailyStandUpProjectStatUserType!]! } @@ -238,6 +246,51 @@ input DjangoModelFilterInput { pk: ID! } +input EventFilter { + id: IDBaseFilterLookup + startDate: DateDateFilterLookup + endDate: DateDateFilterLookup + types: [EventTypeEnum!]! + AND: EventFilter + OR: EventFilter + NOT: EventFilter + DISTINCT: Boolean +} + +input EventOrder { + id: Ordering + name: Ordering + startDate: Ordering + endDate: Ordering +} + +type EventType implements UserResourceTypeMixin { + createdAt: DateTime! + modifiedAt: DateTime! + createdBy: UserType! + modifiedBy: UserType! + id: ID! + startDate: Date! + endDate: Date! + name: String! + type: EventTypeEnum! + typeDisplay: String! + dates: [Date!]! +} + +type EventTypeCountList { + limit: Int! + offset: Int! + count: Int! + items: [EventType!]! +} + +enum EventTypeEnum { + HOLIDAY + RETREAT + MISC +} + input IDBaseFilterLookup { """Exact match. Filter will be skipped on `null` value""" exact: ID @@ -380,6 +433,8 @@ type PrivateQuery { contract(pk: ID!): ContractType task(pk: ID!): TaskType journal(date: Date!): JournalType + events(filters: EventFilter, order: EventOrder, pagination: OffsetPaginationInput): EventTypeCountList! + relativeEvents: [EventType!]! id: ID! } @@ -542,9 +597,9 @@ scalar TimeDuration input TimeEntryBulkCreateInput { task: ID! date: Date! - type: TimeEntryTypeEnum! status: TimeEntryStatusEnum! id: ID + type: TimeEntryTypeEnum description: String duration: TimeDuration startTime: Time @@ -554,8 +609,8 @@ input TimeEntryBulkCreateInput { input TimeEntryCreateInput { task: ID! date: Date! - type: TimeEntryTypeEnum! status: TimeEntryStatusEnum! + type: TimeEntryTypeEnum description: String duration: TimeDuration startTime: Time @@ -598,8 +653,8 @@ type TimeEntryType implements ClientIdMixin { duration: TimeDuration status: TimeEntryStatusEnum! statusDisplay: String! - type: TimeEntryTypeEnum! - typeDisplay: String! + type: TimeEntryTypeEnum + typeDisplay: String description: String user: UserType! task: TaskType! From f8a5e63f85e1009380c356f650c7ef5a9fd72d1c Mon Sep 17 00:00:00 2001 From: thenav56 Date: Fri, 23 Aug 2024 13:45:48 +0545 Subject: [PATCH 31/51] Add sentry middleware to track performances --- main/middlewares.py | 31 +++++++++++++++++++++++++++++++ main/settings.py | 1 + main/urls.py | 1 + 3 files changed, 33 insertions(+) create mode 100644 main/middlewares.py diff --git a/main/middlewares.py b/main/middlewares.py new file mode 100644 index 0000000..0579f24 --- /dev/null +++ b/main/middlewares.py @@ -0,0 +1,31 @@ +import json + +from django.urls import reverse +from sentry_sdk import Scope + + +class SentryTransactionMiddleware: + graphql_url = reverse("graphql") + + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + if request.path == self.graphql_url: + operation_type = "Query" + operation_name = "Unknown" + try: + body = request.body.decode("utf-8") + if body: + # XXX: This will be repeated by Strawberry as well. + data = json.loads(body) + operation_name = data.get("operationName", operation_name) + if data.get("query", "").startswith("mutation"): + operation_type = "Mutation" + except Exception: + ... + + scope = Scope.get_current_scope() + scope.set_transaction_name(f"GraphQL/{operation_type}/{operation_name}") + + return self.get_response(request) diff --git a/main/settings.py b/main/settings.py index da32296..003c97d 100644 --- a/main/settings.py +++ b/main/settings.py @@ -154,6 +154,7 @@ "django.contrib.auth.middleware.AuthenticationMiddleware", "django.contrib.messages.middleware.MessageMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware", + "main.middlewares.SentryTransactionMiddleware", ] ROOT_URLCONF = "main.urls" diff --git a/main/urls.py b/main/urls.py index dd7b9cf..cb4bfb5 100644 --- a/main/urls.py +++ b/main/urls.py @@ -22,6 +22,7 @@ schema=graphql_schema, graphql_ide=False, ), + name="graphql", ), path("o/google", google_oauth, name="google_oauth"), ] From 9d405e5d1c42e0e35b40ad5fb52c4954d93b195f Mon Sep 17 00:00:00 2001 From: thenav56 Date: Fri, 23 Aug 2024 14:19:55 +0545 Subject: [PATCH 32/51] Fix sentry configuration --- main/settings.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/main/settings.py b/main/settings.py index 003c97d..a1faaef 100644 --- a/main/settings.py +++ b/main/settings.py @@ -54,7 +54,8 @@ AWS_S3_ENDPOINT_URL=(str, None), # Optional # Sentry SENTRY_DSN=(str, None), - SENTRY_SAMPLE_RATE=(float, 0.2), + SENTRY_TRACES_SAMPLE_RATE=(float, 0.2), + SENTRY_PROFILE_SAMPLE_RATE=(float, 0.2), # App Domain APP_DOMAIN=str, # api.example.com APP_HTTP_PROTOCOL=str, # http|https @@ -319,7 +320,6 @@ # Sentry Config SENTRY_DSN = env("SENTRY_DSN") -SENTRY_SAMPLE_RATE = env("SENTRY_SAMPLE_RATE") SENTRY_ENABLED = False SENTRY_CONFIG = { @@ -328,6 +328,8 @@ "send_default_pii": True, "release": env("RELEASE"), "environment": APP_ENVIRONMENT, + "traces_sample_rate": env("SENTRY_TRACES_SAMPLE_RATE"), + "profiles_sample_rate": env("SENTRY_PROFILE_SAMPLE_RATE"), "debug": DEBUG, "tags": { "site": ",".join(set(ALLOWED_HOSTS)), From 3d45bdc2c59a28ee929dcac6b6fe1ad00621fe84 Mon Sep 17 00:00:00 2001 From: thenav56 Date: Fri, 23 Aug 2024 15:07:07 +0545 Subject: [PATCH 33/51] Sentry config clean-up - Is it bit complex to ignore graphql errors - For now ignoring at sentry server-side --- main/sentry.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/main/sentry.py b/main/sentry.py index 4c1a3c8..995eb00 100644 --- a/main/sentry.py +++ b/main/sentry.py @@ -4,11 +4,8 @@ from sentry_sdk.integrations.logging import ignore_logger from sentry_sdk.integrations.redis import RedisIntegration from sentry_sdk.integrations.strawberry import StrawberryIntegration -from strawberry.permission import BasePermission -IGNORED_ERRORS = [ - BasePermission, -] +IGNORED_ERRORS = [] IGNORED_LOGGERS = [ "graphql.execution.utils", "strawberry.http.exceptions.HTTPException", From f969ea94fc26c5efbeb57c1575912cc5f7c25e4f Mon Sep 17 00:00:00 2001 From: thenav56 Date: Mon, 26 Aug 2024 07:23:14 +0545 Subject: [PATCH 34/51] CORS Allow headers for sentry --- main/settings.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/main/settings.py b/main/settings.py index a1faaef..d9af607 100644 --- a/main/settings.py +++ b/main/settings.py @@ -13,6 +13,7 @@ from pathlib import Path import environ +from corsheaders.defaults import default_headers from main import sentry @@ -306,16 +307,14 @@ ) CORS_ALLOW_HEADERS = ( - "accept", + *default_headers, + # Misc "accept-encoding", - "authorization", "content-type", - "dnt", "origin", - "user-agent", - "x-csrftoken", - "x-requested-with", + # Sentry "sentry-trace", + "baggage", ) # Sentry Config From f643bca0138695f0fbe00a8acf53918b2c391485 Mon Sep 17 00:00:00 2001 From: thenav56 Date: Mon, 26 Aug 2024 18:21:51 +0545 Subject: [PATCH 35/51] Feedback changes - Sort by user name instead of id in standup - Return department on slide and user info - Add slide order in project - Properly handle events cache - Add is_superuser staff in UserMe - Fix TimeEntry types filter - Add additional fields to UserType - Deprecated standup fields --- apps/common/dataloaders.py | 56 ++++++++++++++++ apps/common/models.py | 20 +++++- apps/journal/dataloaders.py | 29 ++++++++ apps/journal/models.py | 18 +++++ apps/project/admin.py | 2 +- .../migrations/0006_project_slide_order.py | 18 +++++ ...0007_project_logo_hd_alter_project_logo.py | 27 ++++++++ apps/project/models.py | 14 ++++ apps/project/queries.py | 2 +- apps/project/types.py | 2 + apps/standup/types.py | 53 +++++++++++---- apps/track/filters.py | 9 ++- apps/track/queries.py | 14 ++++ apps/user/admin.py | 1 + apps/user/enums.py | 11 +++- ...ude_from_slides_alter_user_display_name.py | 23 +++++++ apps/user/models.py | 5 +- apps/user/queries.py | 22 +------ apps/user/types.py | 61 +++++++++++++++-- schema.graphql | 66 +++++++++++++++---- 20 files changed, 393 insertions(+), 60 deletions(-) create mode 100644 apps/project/migrations/0006_project_slide_order.py create mode 100644 apps/project/migrations/0007_project_logo_hd_alter_project_logo.py create mode 100644 apps/user/migrations/0005_user_exclude_from_slides_alter_user_display_name.py diff --git a/apps/common/dataloaders.py b/apps/common/dataloaders.py index 62cb81b..eb02af1 100644 --- a/apps/common/dataloaders.py +++ b/apps/common/dataloaders.py @@ -1,10 +1,20 @@ import typing +# from asgiref.sync import sync_to_async from django.db import models +# import datetime + + +# from apps.common.models import Event +# from apps.journal.models import Journal +# from django.utils.functional import cached_property +# from strawberry.dataloader import DataLoader + DjangoModel = typing.TypeVar("DjangoModel", bound=models.Model) +# -- Helper def load_model_objects( Model: typing.Type[DjangoModel], keys: list[int], @@ -12,3 +22,49 @@ def load_model_objects( qs = Model.objects.filter(id__in=keys) _map = {obj.pk: obj for obj in qs} return [_map[key] for key in keys] + + +# -- Common models dataloaders + +# def load_user_last_working_date(keys: list[int]) -> list[datetime.date]: +# """ +# WITH recursive_days AS ( +# SELECT +# DATE_SUB(:input_date, INTERVAL 1 DAY) AS prev_day +# UNION ALL +# SELECT +# DATE_SUB(prev_day, INTERVAL 1 DAY) +# FROM recursive_days +# WHERE +# prev_day NOT IN ( +# SELECT +# {Event.date.db_column} +# FROM {Event._meta.db_table} +# WHERE {Event.type.db_column} IN ( +# {Event.Type.HOLIDAY.value}, +# {Event.Type.RETREAT.value} +# ) +# ) +# AND DAYOFWEEK(prev_day) NOT IN (1, 7) -- 1 = Sunday, 7 = Saturday +# AND ( +# SELECT COUNT(*) FROM recursive_days +# ) < %(NUMBER_OF_WORKING_DAYS_TO_SKIP + 1)s -- Stop after finding N working days +# ) +# SELECT MIN(prev_day) AS date_before_n_working_days +# FROM recursive_days; +# """ + +# recent_user_leave_dates_qs = ( +# Journal.as_leave_qs(recent_only=True) +# .filter(user__in=keys) +# .values_list('user', 'date') +# ) +# print(recent_user_leave_dates_qs) +# return load_model_objects(User, keys) + + +class CommonLoader: + # @cached_property + # def load_user_last_working_date(self): + # return DataLoader(load_fn=sync_to_async(load_user_last_working_date)) + ... diff --git a/apps/common/models.py b/apps/common/models.py index ae70976..79a80c6 100644 --- a/apps/common/models.py +++ b/apps/common/models.py @@ -1,6 +1,7 @@ import datetime import functools +from asgiref.sync import sync_to_async from django.db import models from django.utils import timezone @@ -68,20 +69,33 @@ def get_dates(self, include_weekends=False) -> list[datetime.date]: def get_last_working_date( cls, now_date: datetime.date, + skip_dates: list[datetime.date] | None = None, offset_count: int | None = None, ) -> datetime.date: # type: ignore[reportReturnType] # TODO: Add test - event_dates = set(cls.get_relative_event_dates()) + dates_to_skip = set(cls.get_relative_event_dates()) + if skip_dates: + dates_to_skip.update(skip_dates) found_count = 0 for x in range(30): # Create a 1 month window, Should be enough date = now_date - datetime.timedelta(days=x) - if cls.is_weekend(date) or date in event_dates: + if cls.is_weekend(date) or date in dates_to_skip: continue if offset_count is not None and found_count < offset_count: found_count += 1 continue return date + @classmethod + @sync_to_async + def aget_last_working_date( + cls, + now_date: datetime.date, + skip_dates: list[datetime.date] | None = None, + offset_count: int | None = None, + ) -> datetime.date: # type: ignore[reportReturnType] + return cls.get_last_working_date(now_date, skip_dates=skip_dates, offset_count=offset_count) + @classmethod def get_relative_events(cls) -> models.QuerySet["Event"]: """ @@ -94,7 +108,7 @@ def get_relative_events(cls) -> models.QuerySet["Event"]: return cls.objects.filter(start_date__gte=start_threshold, end_date__lte=end_threshold) @classmethod - @functools.cache + @functools.cache # TODO: URGENT! Clear this cache on events CUD def get_relative_event_dates(cls) -> list[datetime.date]: """ Return list of dates with holiday relative to current date diff --git a/apps/journal/dataloaders.py b/apps/journal/dataloaders.py index 8439181..85be6b4 100644 --- a/apps/journal/dataloaders.py +++ b/apps/journal/dataloaders.py @@ -1,6 +1,7 @@ import datetime from asgiref.sync import sync_to_async +from django.utils import timezone from django.utils.functional import cached_property from strawberry.dataloader import DataLoader @@ -39,6 +40,26 @@ def load_user_work_from_home(keys: list[tuple[int, datetime.date]]) -> list[Jour return [_map.get(key) for key in keys] +def load_user_leave_today(keys: list[int]) -> list[Journal.LeaveType | None]: + qs = Journal.objects.filter( + user__in=keys, + date=timezone.now().date(), + ).values_list("user_id", "leave_type") + + _map = {user_id: leave_type for user_id, leave_type in qs} + return [_map.get(key) for key in keys] + + +def load_user_work_from_home_today(keys: list[int]) -> list[Journal.WorkFromHomeType | None]: + qs = Journal.objects.filter( + user__in=keys, + date=timezone.now().date(), + ).values_list("user_id", "wfh_type") + + _map = {user_id: wfh_type for user_id, wfh_type in qs} + return [_map.get(key) for key in keys] + + class JournalDataLoader: @cached_property def load_user_leave(self): @@ -47,3 +68,11 @@ def load_user_leave(self): @cached_property def load_user_work_from_home(self): return DataLoader(load_fn=sync_to_async(load_user_work_from_home)) + + @cached_property + def load_user_leave_today(self): + return DataLoader(load_fn=sync_to_async(load_user_leave_today)) + + @cached_property + def load_user_work_from_home_today(self): + return DataLoader(load_fn=sync_to_async(load_user_work_from_home_today)) diff --git a/apps/journal/models.py b/apps/journal/models.py index 64396fa..87702ac 100644 --- a/apps/journal/models.py +++ b/apps/journal/models.py @@ -1,5 +1,8 @@ +import datetime + from django.core.exceptions import ValidationError from django.db import models +from django.utils import timezone from django.utils.translation import gettext_lazy as _ from apps.user.models import User @@ -49,6 +52,21 @@ class Meta: # type: ignore[reportIncompatibleVariableOverride] models.Index(fields=["date"]), ] + @classmethod + def as_leave_qs(cls, recent_only=False) -> models.QuerySet["Journal"]: + """ + Return a Journal queryset with pre-applied leave filters + """ + qs = Journal.objects.filter( + leave_type__in=[ + Journal.LeaveType.FULL, + Journal.LeaveType.FIRST_HALF, + ], + ) + if recent_only: + return qs.filter(date__gte=timezone.now() - datetime.timedelta(days=30)) + return qs + def __str__(self): return f"{self.user_id}#{self.date}" diff --git a/apps/project/admin.py b/apps/project/admin.py index b936107..cb9167f 100644 --- a/apps/project/admin.py +++ b/apps/project/admin.py @@ -38,4 +38,4 @@ class ProjectAdmin(PreventDeleteAdminMixin, VersionAdmin, UserResourceAdmin): AutocompleteFilterFactory("Contractor", "contractor"), ) - list_display = ("name",) + list_display = ("name", "slide_order") diff --git a/apps/project/migrations/0006_project_slide_order.py b/apps/project/migrations/0006_project_slide_order.py new file mode 100644 index 0000000..b5b3e5e --- /dev/null +++ b/apps/project/migrations/0006_project_slide_order.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.15 on 2024-08-26 12:01 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("project", "0005_project_is_archived_deadline"), + ] + + operations = [ + migrations.AddField( + model_name="project", + name="slide_order", + field=models.PositiveSmallIntegerField(default=0, help_text="Used to order projects in daily stand-up slides"), + ), + ] diff --git a/apps/project/migrations/0007_project_logo_hd_alter_project_logo.py b/apps/project/migrations/0007_project_logo_hd_alter_project_logo.py new file mode 100644 index 0000000..ab8e6b7 --- /dev/null +++ b/apps/project/migrations/0007_project_logo_hd_alter_project_logo.py @@ -0,0 +1,27 @@ +# Generated by Django 4.2.15 on 2024-08-26 12:32 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("project", "0006_project_slide_order"), + ] + + operations = [ + migrations.AddField( + model_name="project", + name="logo_hd", + field=models.ImageField( + blank=True, help_text="Hight quality logo", max_length=255, null=True, upload_to="project/logo-hd/" + ), + ), + migrations.AlterField( + model_name="project", + name="logo", + field=models.ImageField( + blank=True, help_text="Low quality logo.", max_length=255, null=True, upload_to="project/logo/" + ), + ), + ] diff --git a/apps/project/models.py b/apps/project/models.py index 35a2aca..ba3980d 100644 --- a/apps/project/models.py +++ b/apps/project/models.py @@ -1,4 +1,5 @@ from django.db import models +from django.utils.translation import gettext_lazy as _ from apps.common.models import UserResource @@ -20,8 +21,17 @@ def __str__(self): class Project(UserResource): name = models.CharField(max_length=225) description = models.TextField(blank=True) + # TODO: Validate image size for optimal performance logo = models.ImageField( upload_to="project/logo/", + help_text="Low quality logo.", + max_length=255, + blank=True, + null=True, + ) + logo_hd = models.ImageField( + upload_to="project/logo-hd/", + help_text="Hight quality logo", max_length=255, blank=True, null=True, @@ -32,6 +42,10 @@ class Project(UserResource): project_client = models.ForeignKey(Client, on_delete=models.PROTECT, related_name="projects") contractor = models.ForeignKey(Contractor, on_delete=models.PROTECT, related_name="projects") is_archived = models.BooleanField(default=False) + slide_order = models.PositiveSmallIntegerField( + default=0, + help_text=_("Used to order projects in daily stand-up slides"), + ) project_client_id: int contractor_id: int diff --git a/apps/project/queries.py b/apps/project/queries.py index eb0d71d..3bf5aff 100644 --- a/apps/project/queries.py +++ b/apps/project/queries.py @@ -33,7 +33,7 @@ class PrivateQuery: # Unbound ---------------------------- @strawberry_django.field async def all_projects(self, info: Info) -> list[ProjectType]: - qs = ProjectType.get_queryset(None, None, info).filter(is_archived=False).all() + qs = ProjectType.get_queryset(None, None, info).filter(is_archived=False).order_by("slide_order").all() return [project async for project in qs] @strawberry_django.field diff --git a/apps/project/types.py b/apps/project/types.py index 6192744..f35f5eb 100644 --- a/apps/project/types.py +++ b/apps/project/types.py @@ -67,6 +67,8 @@ async def remaining_days(self, root: strawberry.Parent[Deadline]) -> int: class ProjectType(UserResourceTypeMixin): id: strawberry.ID logo: strawberry.auto + logo_hd: strawberry.auto + slide_order: strawberry.auto project_client_id: strawberry.ID contractor_id: strawberry.ID diff --git a/apps/standup/types.py b/apps/standup/types.py index 9bbb66c..0dfc9f8 100644 --- a/apps/standup/types.py +++ b/apps/standup/types.py @@ -2,7 +2,6 @@ import strawberry import strawberry_django -from asgiref.sync import sync_to_async from django.db import models from apps.common.models import Event @@ -13,6 +12,7 @@ from apps.standup.models import Quote from apps.track.models import TimeEntry from apps.user.models import User +from apps.user.types import UserType from main.graphql.context import Info from utils.common import get_queryset_for_model from utils.strawberry.types import string_field @@ -35,18 +35,21 @@ class DailyStandUpProjectStatUserType: user_obj: strawberry.Private[User] date: strawberry.Private[datetime.date] - @strawberry.field - def id(self) -> strawberry.ID: - return strawberry.ID(str(self.user_obj.pk)) + id: strawberry.ID + last_active_date: datetime.date - @strawberry.field + @strawberry.field(deprecation_reason="Use user.display_picture instead") def display_picture(self) -> str | None: return self.user_obj.display_picture - @strawberry.field + @strawberry.field(deprecation_reason="Use user.display_name instead") def display_name(self) -> str: return self.user_obj.display_name + @strawberry.field + def user(self) -> UserType: + return self.user_obj # type: ignore[reportReturnType] + @strawberry.field async def leave(self, info: Info) -> JournalLeaveTypeEnum | None: # type: ignore[reportInvalidTypeForm] return await info.context.dl.journal.load_user_leave.load((self.user_obj.pk, self.date)) @@ -62,11 +65,11 @@ class DailyStandUpProjectStatType: date: strawberry.Private[datetime.date] async def _check_activity_from_date(self) -> datetime.date: - return await sync_to_async(Event.get_last_working_date)(now_date=self.date, offset_count=3) + return await Event.aget_last_working_date(now_date=self.date, offset_count=3) @strawberry.field async def last_working_date(self) -> datetime.date: - return await sync_to_async(Event.get_last_working_date)(now_date=self.date) + return await Event.aget_last_working_date(now_date=self.date) # XXX: For debugging only @strawberry.field @@ -79,18 +82,42 @@ def project(self) -> ProjectType: @strawberry.field async def users(self) -> list[DailyStandUpProjectStatUserType]: - last_working_date = await self._check_activity_from_date() + activity_from_date = await self._check_activity_from_date() time_entries_qs = ( TimeEntry.objects.filter( + ( + models.Q( + date__gte=activity_from_date, + date__lt=self.date, + status__in=[TimeEntry.Status.DOING, TimeEntry.Status.DONE], + ) + | models.Q(date=self.date) + ), task__contract__project=self.project_obj, - date__gte=last_working_date, ) + .order_by() .values("user") - .distinct() + .annotate( + active_date=models.Max("date"), + ) + .values_list("user", "active_date") ) + + time_entries_user_active_date_map = {user_id: active_date async for user_id, active_date in time_entries_qs} + + users_qs = User.objects.filter( + id__in=time_entries_user_active_date_map.keys(), + exclude_from_slides=False, + ).order_by("display_name") + return [ - DailyStandUpProjectStatUserType(user_obj=user, date=self.date) - async for user in User.objects.filter(id__in=time_entries_qs).all() + DailyStandUpProjectStatUserType( + user_obj=user, + date=self.date, + last_active_date=time_entries_user_active_date_map[user.pk], + id=strawberry.ID(f"{user.pk}-{self.project_obj.pk}"), + ) + async for user in users_qs.all() ] diff --git a/apps/track/filters.py b/apps/track/filters.py index fcbc16d..972d36a 100644 --- a/apps/track/filters.py +++ b/apps/track/filters.py @@ -36,7 +36,14 @@ class TimeEntryFilter: task: strawberry.auto date: strawberry.auto - types: list[TimeEntryTypeEnum] # type: ignore[reportInvalidTypeForm] + @strawberry_django.filter_field + def types( + self, + queryset: models.QuerySet, + value: list[TimeEntryTypeEnum], # type: ignore[reportInvalidTypeForm] + prefix: str, + ) -> tuple[models.QuerySet, models.Q]: + return queryset, models.Q(**{f"{prefix}type__in": value}) @strawberry_django.filter_field def project( diff --git a/apps/track/queries.py b/apps/track/queries.py index 009caec..8d614f7 100644 --- a/apps/track/queries.py +++ b/apps/track/queries.py @@ -2,6 +2,7 @@ import strawberry import strawberry_django +from strawberry_django.filters import apply as apply_filters from main.graphql.context import Info from utils.strawberry.paginations import CountList, pagination_field @@ -55,6 +56,19 @@ async def my_time_entries(self, info: Info, date: datetime.date) -> list[TimeEnt ) return [time_entry async for time_entry in qs] + @strawberry_django.field + async def all_time_entries( + self, + info: Info, + filters: TimeEntryFilter, + ) -> list[TimeEntryType]: + queryset = TimeEntryType.get_queryset(None, None, info) + queryset = apply_filters(filters, queryset, info, None) + count = await queryset.acount() + if count > 3000: # TODO: Is this fine? + raise Exception(f"Try using filters. To much data to return (Row count: {count})") + return [time_entry async for time_entry in queryset] + # Single ---------------------------- @strawberry_django.field async def contract(self, info: Info, pk: strawberry.ID) -> ContractType | None: diff --git a/apps/user/admin.py b/apps/user/admin.py index e6cd2ff..1e91972 100644 --- a/apps/user/admin.py +++ b/apps/user/admin.py @@ -30,6 +30,7 @@ class UserAdmin(DjangoUserAdmin): "last_name", "department", "display_picture", + "exclude_from_slides", ) }, ), diff --git a/apps/user/enums.py b/apps/user/enums.py index 052db48..3c76064 100644 --- a/apps/user/enums.py +++ b/apps/user/enums.py @@ -1 +1,10 @@ -enum_map = {} +import strawberry + +from utils.strawberry.enums import get_enum_name_from_django_field + +from .models import User + +UserDepartmentTypeEnum = strawberry.enum(User.Department, name="UserDepartmentTypeEnum") + + +enum_map = {get_enum_name_from_django_field(field): enum for field, enum in ((User.department, UserDepartmentTypeEnum),)} diff --git a/apps/user/migrations/0005_user_exclude_from_slides_alter_user_display_name.py b/apps/user/migrations/0005_user_exclude_from_slides_alter_user_display_name.py new file mode 100644 index 0000000..424a83e --- /dev/null +++ b/apps/user/migrations/0005_user_exclude_from_slides_alter_user_display_name.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2.15 on 2024-08-26 12:01 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("user", "0004_user_display_picture_alter_user_department"), + ] + + operations = [ + migrations.AddField( + model_name="user", + name="exclude_from_slides", + field=models.BooleanField(default=False), + ), + migrations.AlterField( + model_name="user", + name="display_name", + field=models.CharField(blank=True, max_length=255), + ), + ] diff --git a/apps/user/models.py b/apps/user/models.py index f9d08a1..c9ede65 100644 --- a/apps/user/models.py +++ b/apps/user/models.py @@ -23,13 +23,16 @@ class Department(models.IntegerChoices): email = models.EmailField(unique=True) invalid_email = models.BooleanField(default=False, help_text=_("Is Bounced email?")) display_name = models.CharField( - verbose_name=_("system generated user display name"), blank=True, max_length=255, ) display_picture = models.URLField(null=True, blank=True) department = models.PositiveSmallIntegerField(choices=Department.choices, null=True, blank=True) + # TODO: This is a hacky way to exclude useres from standup slides, for better integration implement + # support for custom teams with members & projects + exclude_from_slides = models.BooleanField(default=False) + objects: CustomUserManager = CustomUserManager() # type: ignore[reportAssignmentType] pk: int diff --git a/apps/user/queries.py b/apps/user/queries.py index 9a4b7da..1e7fdd5 100644 --- a/apps/user/queries.py +++ b/apps/user/queries.py @@ -1,29 +1,9 @@ import strawberry -import strawberry_django from asgiref.sync import sync_to_async from main.graphql.context import Info -from .models import User - - -@strawberry_django.type(User) -class UserType: - id: strawberry.ID - first_name: strawberry.auto - last_name: strawberry.auto - display_name: strawberry.auto - display_picture: strawberry.auto - - -@strawberry_django.type(User) -class UserMeType(UserType): - id: strawberry.ID - email: strawberry.auto - first_name: strawberry.auto - last_name: strawberry.auto - display_name: strawberry.auto - display_picture: strawberry.auto +from .types import UserMeType @strawberry.type diff --git a/apps/user/types.py b/apps/user/types.py index 7a25e54..f2cc346 100644 --- a/apps/user/types.py +++ b/apps/user/types.py @@ -1,15 +1,62 @@ +import datetime + import strawberry import strawberry_django +from django.utils import timezone + +from apps.common.models import Event +from apps.journal.enums import JournalLeaveTypeEnum, JournalWorkFromHomeTypeEnum +from apps.journal.models import Journal +from main.graphql.context import Info +from utils.strawberry.enums import enum_display_field, enum_field +from utils.strawberry.types import string_field from .models import User -@strawberry_django.type(User) -class UserType: +@strawberry.interface +class UserBaseType: + # NOTE: Can't use strawberry.auto on interface id: strawberry.ID - first_name: strawberry.auto - last_name: strawberry.auto + first_name = string_field(User.first_name) + last_name = string_field(User.last_name) + display_name = string_field(User.display_name) # type: ignore[reportArgumentType] + display_picture = string_field(User.display_picture) + + department = enum_field(User.department) + department_display = enum_display_field(User.department) + + @strawberry.field + async def leave_today( + self, + user: strawberry.Parent[User], + info: Info, + ) -> JournalLeaveTypeEnum | None: # type: ignore[reportInvalidTypeForm] + return await info.context.dl.journal.load_user_leave_today.load(user.pk) + + @strawberry.field + async def work_from_home_today( + self, + user: strawberry.Parent[User], + info: Info, + ) -> JournalWorkFromHomeTypeEnum | None: # type: ignore[reportInvalidTypeForm] + return await info.context.dl.journal.load_user_work_from_home_today.load(user.pk) + + +@strawberry_django.type(User) +class UserType(UserBaseType): ... + + +@strawberry_django.type(User) +class UserMeType(UserBaseType): + email: strawberry.auto + is_staff: strawberry.auto + is_superuser: strawberry.auto - @strawberry_django.field - def display_name(self, root: User) -> str: - return root.display_name + @strawberry.field + async def my_last_working_date(self) -> datetime.date: + recent_leaves_dates = list(Journal.as_leave_qs(recent_only=True).values_list("date", flat=True).distinct()) + return await Event.aget_last_working_date( + now_date=timezone.now().date(), + skip_dates=recent_leaves_dates, + ) diff --git a/schema.graphql b/schema.graphql index da29864..e0d19fc 100644 --- a/schema.graphql +++ b/schema.graphql @@ -1,5 +1,6 @@ type AppEnumCollection { EventType: [AppEnumCollectionEventType!]! + UserDepartment: [AppEnumCollectionUserDepartment!]! TimeEntryType: [AppEnumCollectionTimeEntryType!]! TimeEntryStatus: [AppEnumCollectionTimeEntryStatus!]! JournalLeaveType: [AppEnumCollectionJournalLeaveType!]! @@ -31,6 +32,11 @@ type AppEnumCollectionTimeEntryType { label: String! } +type AppEnumCollectionUserDepartment { + key: UserDepartmentTypeEnum! + label: String! +} + input BoolBaseFilterLookup { """Exact match. Filter will be skipped on `null` value""" exact: Boolean @@ -160,8 +166,10 @@ type DailyStandUpProjectStatType { type DailyStandUpProjectStatUserType { id: ID! - displayPicture: String - displayName: String! + lastActiveDate: Date! + displayPicture: String @deprecated(reason: "Use user.display_picture instead") + displayName: String! @deprecated(reason: "Use user.display_name instead") + user: UserType! leave: JournalLeaveTypeEnum workFromHome: JournalWorkFromHomeTypeEnum } @@ -430,6 +438,7 @@ type PrivateQuery { """Return all UnArchived tasks""" allActiveTasks: [TaskType!]! myTimeEntries(date: Date!): [TimeEntryType!]! + allTimeEntries(filters: TimeEntryFilter!): [TimeEntryType!]! contract(pk: ID!): ContractType task(pk: ID!): TaskType journal(date: Date!): JournalType @@ -460,6 +469,8 @@ type ProjectType implements UserResourceTypeMixin { modifiedBy: UserType! id: ID! logo: DjangoImageType + logoHd: DjangoImageType + slideOrder: Int! projectClientId: ID! contractorId: ID! name: String! @@ -622,11 +633,11 @@ input TimeEntryFilter { user: DjangoModelFilterInput task: DjangoModelFilterInput date: DateDateFilterLookup - types: [TimeEntryTypeEnum!]! AND: TimeEntryFilter OR: TimeEntryFilter NOT: TimeEntryFilter DISTINCT: Boolean + types: [TimeEntryTypeEnum!] project: ID contract: ID } @@ -702,13 +713,41 @@ input TimeEntryUpdateInput { clientId: ID } -type UserMeType { +interface UserBaseType { id: ID! - firstName: String! - lastName: String! - displayName: String! + firstName: String + lastName: String + displayName: String displayPicture: String + department: UserDepartmentTypeEnum + departmentDisplay: String + leaveToday: JournalLeaveTypeEnum + workFromHomeToday: JournalWorkFromHomeTypeEnum +} + +enum UserDepartmentTypeEnum { + DATA_ANALYST + DESIGN + DEVELOPMENT + MANAGEMENT + PROJECT_MANAGER + QUALITY_ASSURANCE +} + +type UserMeType implements UserBaseType { + id: ID! + firstName: String + lastName: String + displayName: String + displayPicture: String + department: UserDepartmentTypeEnum + departmentDisplay: String + leaveToday: JournalLeaveTypeEnum + workFromHomeToday: JournalWorkFromHomeTypeEnum email: String! + isStaff: Boolean! + isSuperuser: Boolean! + myLastWorkingDate: Date! } type UserMeTypeMutationResponseType { @@ -724,9 +763,14 @@ interface UserResourceTypeMixin { modifiedBy: UserType! } -type UserType { +type UserType implements UserBaseType { id: ID! - firstName: String! - lastName: String! - displayName: String! + firstName: String + lastName: String + displayName: String + displayPicture: String + department: UserDepartmentTypeEnum + departmentDisplay: String + leaveToday: JournalLeaveTypeEnum + workFromHomeToday: JournalWorkFromHomeTypeEnum } \ No newline at end of file From 226ebb3bf148f3cf88ec1408826da0c0acebb6a0 Mon Sep 17 00:00:00 2001 From: thenav56 Date: Tue, 27 Aug 2024 17:20:07 +0545 Subject: [PATCH 36/51] Add more time entries types - Meeting (Internal) - Testing - Research - OPERATION --- .../migrations/0017_alter_timeentry_type.py | 71 +++++++++++++++++++ apps/track/models.py | 32 ++++++--- schema.graphql | 13 ++-- 3 files changed, 101 insertions(+), 15 deletions(-) create mode 100644 apps/track/migrations/0017_alter_timeentry_type.py diff --git a/apps/track/migrations/0017_alter_timeentry_type.py b/apps/track/migrations/0017_alter_timeentry_type.py new file mode 100644 index 0000000..651afc7 --- /dev/null +++ b/apps/track/migrations/0017_alter_timeentry_type.py @@ -0,0 +1,71 @@ +# Generated by Django 4.2.15 on 2024-08-27 11:14 + +from django.db import migrations, models + +TIME_ENTRY_TYPE_MIGRATION = { + 1000: 1, # Design + 1200: 2, # DevOps + 1100: 3, # Development + 3100: 4, # Discussion (External) + 3000: 5, # Discussion (Internal) + 2000: 6, # Documentation + 4000: 7, # Meeting (External) + 5000: 10, # Project Management + 6000: 12, # Testing +} + + +def migrate_time_entry_data(apps, _): + TimeEntry = apps.get_model("track", "TimeEntry") + qs = TimeEntry.objects.all() + + print("Migrating data....") + for existing_type_value, new_type_value in TIME_ENTRY_TYPE_MIGRATION.items(): + updated = qs.filter(type=existing_type_value).update(type=new_type_value) + print(f"\t {existing_type_value} -> {new_type_value}: items updated: {updated}") + + +def revert_migrate_time_entry_data(apps, _): + TimeEntry = apps.get_model("track", "TimeEntry") + qs = TimeEntry.objects.all() + + print("Migrating back data....") + for existing_type_value, new_type_value in TIME_ENTRY_TYPE_MIGRATION.items(): + updated = qs.filter(type=new_type_value).update(type=existing_type_value) + print(f"\t {new_type_value} -> {existing_type_value}: items updated: {updated}") + + +class Migration(migrations.Migration): + + dependencies = [ + ("track", "0016_alter_timeentry_type"), + ] + + operations = [ + migrations.AlterField( + model_name="timeentry", + name="type", + field=models.PositiveSmallIntegerField( + blank=True, + choices=[ + (6, "Documentation"), + (11, "Research"), + (1, "Design"), + (9, "Operation"), + (10, "Project Management"), + (12, "Testing"), + (3, "Development"), + (2, "DevOps"), + (4, "Discussion (External)"), + (5, "Discussion (Internal)"), + (7, "Meeting (External)"), + (8, "Meeting (Internal)"), + ], + null=True, + ), + ), + migrations.RunPython( + migrate_time_entry_data, + reverse_code=revert_migrate_time_entry_data, + ), + ] diff --git a/apps/track/models.py b/apps/track/models.py index 0b2dc26..ca05600 100644 --- a/apps/track/models.py +++ b/apps/track/models.py @@ -38,16 +38,28 @@ def __str__(self): class TimeEntry(models.Model): class Type(models.IntegerChoices): - # Using 4 digit for future ordering support - DESIGN = 1000, _("Design") - DEVELOPMENT = 1100, _("Development") - DEV_OPS = 1200, _("DevOps") - DOCUMENTATION = 2000, _("Documentation") - INTERNAL_DISCUSSION = 3000, _("Internal Discussion") - EXTERNAL_DISCUSSION = 3100, _("External Discussion") - MEETING = 4000, _("Meeting") - PROJECT_MANAGEMENT = 5000, _("Project Management") - QUALITY_ASSURANCE = 6000, _("QA") + # XXX: Custom integer value is used to support sort by label + + # For MISC, user will leave it empty + # Generic + DOCUMENTATION = 6, _("Documentation") + RESEARCH = 11, _("Research") + DESIGN = 1, _("Design") + OPERATION = 9, _("Operation") + PROJECT_MANAGEMENT = 10, _("Project Management") + TESTING = 12, _("Testing") + + # Development + DEVELOPMENT = 3, _("Development") + DEV_OPS = 2, _("DevOps") + + # Communication + # - Discussion + EXTERNAL_DISCUSSION = 4, _("Discussion (External)") + INTERNAL_DISCUSSION = 5, _("Discussion (Internal)") + # - Meeting + EXTERNAL_MEETING = 7, _("Meeting (External)") + INTERNAL_MEETING = 8, _("Meeting (Internal)") class Status(models.IntegerChoices): DOING = 1, _("Doing") diff --git a/schema.graphql b/schema.graphql index e0d19fc..345da49 100644 --- a/schema.graphql +++ b/schema.graphql @@ -685,15 +685,18 @@ type TimeEntryTypeCountList { } enum TimeEntryTypeEnum { + DOCUMENTATION + RESEARCH DESIGN + OPERATION + PROJECT_MANAGEMENT + TESTING DEVELOPMENT DEV_OPS - DOCUMENTATION - INTERNAL_DISCUSSION EXTERNAL_DISCUSSION - MEETING - PROJECT_MANAGEMENT - QUALITY_ASSURANCE + INTERNAL_DISCUSSION + EXTERNAL_MEETING + INTERNAL_MEETING } type TimeEntryTypeMutationResponseType { From 47b537f1cda0cf7681aea5626cf96d8ec924993a Mon Sep 17 00:00:00 2001 From: thenav56 Date: Tue, 27 Aug 2024 17:32:57 +0545 Subject: [PATCH 37/51] Add missing ID on DailyStandUpType->Project --- apps/standup/types.py | 9 +++++++-- schema.graphql | 1 + 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/apps/standup/types.py b/apps/standup/types.py index 0dfc9f8..8353725 100644 --- a/apps/standup/types.py +++ b/apps/standup/types.py @@ -35,7 +35,7 @@ class DailyStandUpProjectStatUserType: user_obj: strawberry.Private[User] date: strawberry.Private[datetime.date] - id: strawberry.ID + id: strawberry.ID # TODO: Use key for auto computed IDs last_active_date: datetime.date @strawberry.field(deprecation_reason="Use user.display_picture instead") @@ -61,6 +61,7 @@ async def work_from_home(self, info: Info) -> JournalWorkFromHomeTypeEnum | None @strawberry.type class DailyStandUpProjectStatType: + id: strawberry.ID project_obj: strawberry.Private[Project] date: strawberry.Private[datetime.date] @@ -139,4 +140,8 @@ async def quote(self, info: Info) -> QuoteType | None: async def project_stat(self, info: Info, pk: strawberry.ID) -> DailyStandUpProjectStatType | None: project = await ProjectType.get_queryset(None, None, info).filter(pk=pk).afirst() if project: - return DailyStandUpProjectStatType(project_obj=project, date=self.date) + return DailyStandUpProjectStatType( + id=strawberry.ID(f"{project.pk}-{self.date.isoformat()}"), + project_obj=project, + date=self.date, + ) diff --git a/schema.graphql b/schema.graphql index 345da49..dbb9d66 100644 --- a/schema.graphql +++ b/schema.graphql @@ -158,6 +158,7 @@ type ContractorTypeCountList { scalar CustomErrorType type DailyStandUpProjectStatType { + id: ID! lastWorkingDate: Date! activityFromDate: Date! project: ProjectType! From 003a736e1bef2c1132bfceebdc1af1d8e6ca1f1a Mon Sep 17 00:00:00 2001 From: thenav56 Date: Tue, 27 Aug 2024 18:37:04 +0545 Subject: [PATCH 38/51] Track proper user info in sentry --- main/middlewares.py | 62 +++++++++++++++++++++++---------------------- main/sentry.py | 38 +++++++++++++++++++++++++++ main/settings.py | 2 +- main/urls.py | 2 +- poetry.lock | 7 ++--- 5 files changed, 76 insertions(+), 35 deletions(-) diff --git a/main/middlewares.py b/main/middlewares.py index 0579f24..9416c4a 100644 --- a/main/middlewares.py +++ b/main/middlewares.py @@ -1,31 +1,33 @@ -import json - +from asgiref.sync import iscoroutinefunction from django.urls import reverse -from sentry_sdk import Scope - - -class SentryTransactionMiddleware: - graphql_url = reverse("graphql") - - def __init__(self, get_response): - self.get_response = get_response - - def __call__(self, request): - if request.path == self.graphql_url: - operation_type = "Query" - operation_name = "Unknown" - try: - body = request.body.decode("utf-8") - if body: - # XXX: This will be repeated by Strawberry as well. - data = json.loads(body) - operation_name = data.get("operationName", operation_name) - if data.get("query", "").startswith("mutation"): - operation_type = "Mutation" - except Exception: - ... - - scope = Scope.get_current_scope() - scope.set_transaction_name(f"GraphQL/{operation_type}/{operation_name}") - - return self.get_response(request) +from django.utils.decorators import sync_and_async_middleware + +from main.sentry import SentryTransactionMiddlewareHelper + + +@sync_and_async_middleware +def sentry_middleware(get_response): + from django.conf import settings + + # One-time configuration and initialization goes here. + graphql_urls = set([reverse("graphql")]) + if settings.DEBUG: + graphql_urls.add(reverse("graphiql")) + + if iscoroutinefunction(get_response): + + async def amiddleware(request): + if settings.SENTRY_ENABLED: + await SentryTransactionMiddlewareHelper.atrack_transaction(graphql_urls, request) + response = await get_response(request) + return response + + return amiddleware + + def middleware(request): + if settings.SENTRY_ENABLED: + SentryTransactionMiddlewareHelper.track_transaction(graphql_urls, request) + response = get_response(request) + return response + + return middleware diff --git a/main/sentry.py b/main/sentry.py index 995eb00..3cebafb 100644 --- a/main/sentry.py +++ b/main/sentry.py @@ -1,4 +1,8 @@ +import json + import sentry_sdk +from asgiref.sync import sync_to_async +from sentry_sdk import Scope, set_user from sentry_sdk.integrations.celery import CeleryIntegration from sentry_sdk.integrations.django import DjangoIntegration from sentry_sdk.integrations.logging import ignore_logger @@ -31,3 +35,37 @@ def init_sentry(app_type, tags={}, **config): scope.set_tag("app_type", app_type) for tag, value in tags.items(): scope.set_tag(tag, value) + + +class SentryTransactionMiddlewareHelper: + @classmethod + @sync_to_async + def atrack_transaction(cls, graphql_urls: set[str], request): + return cls.track_transaction(graphql_urls, request) + + @staticmethod + def track_transaction(graphql_urls: set[str], request): + if request.path in graphql_urls: + operation_type = "Query" + operation_name = "Unknown" + try: + body = request.body.decode("utf-8") + if body: + # XXX: This will be repeated by Strawberry as well. + data = json.loads(body) + operation_name = data.get("operationName", operation_name) + if data.get("query", "").startswith("mutation"): + operation_type = "Mutation" + except Exception: + ... + + scope = Scope.get_current_scope() + scope.set_transaction_name(f"GraphQL/{operation_type}/{operation_name}") + if (user := request.user) and user.pk: + set_user( + { + "id": user.pk, + "email": user.email, + "is_superuser": user.is_superuser, + } + ) diff --git a/main/settings.py b/main/settings.py index d9af607..c74427c 100644 --- a/main/settings.py +++ b/main/settings.py @@ -156,7 +156,7 @@ "django.contrib.auth.middleware.AuthenticationMiddleware", "django.contrib.messages.middleware.MessageMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware", - "main.middlewares.SentryTransactionMiddleware", + "main.middlewares.sentry_middleware", ] ROOT_URLCONF = "main.urls" diff --git a/main/urls.py b/main/urls.py index cb4bfb5..97bcb58 100644 --- a/main/urls.py +++ b/main/urls.py @@ -31,7 +31,7 @@ if settings.DEBUG: urlpatterns.extend( [ - path("graphiql/", CustomAsyncGraphQLView.as_view(schema=graphql_schema)), + path("graphiql/", CustomAsyncGraphQLView.as_view(schema=graphql_schema), name="graphiql"), path("dev/sign_in/", dev_sign_in, name="dev-sign-in"), ] ) diff --git a/poetry.lock b/poetry.lock index 0fdbe78..ac6577e 100644 --- a/poetry.lock +++ b/poetry.lock @@ -2021,13 +2021,13 @@ crt = ["botocore[crt] (>=1.33.2,<2.0a.0)"] [[package]] name = "sentry-sdk" -version = "2.12.0" +version = "2.13.0" description = "Python client for Sentry (https://sentry.io)" optional = false python-versions = ">=3.6" files = [ - {file = "sentry_sdk-2.12.0-py2.py3-none-any.whl", hash = "sha256:7a8d5163d2ba5c5f4464628c6b68f85e86972f7c636acc78aed45c61b98b7a5e"}, - {file = "sentry_sdk-2.12.0.tar.gz", hash = "sha256:8763840497b817d44c49b3fe3f5f7388d083f2337ffedf008b2cdb63b5c86dc6"}, + {file = "sentry_sdk-2.13.0-py2.py3-none-any.whl", hash = "sha256:6beede8fc2ab4043da7f69d95534e320944690680dd9a963178a49de71d726c6"}, + {file = "sentry_sdk-2.13.0.tar.gz", hash = "sha256:8d4a576f7a98eb2fdb40e13106e41f330e5c79d72a68be1316e7852cf4995260"}, ] [package.dependencies] @@ -2054,6 +2054,7 @@ httpx = ["httpx (>=0.16.0)"] huey = ["huey (>=2)"] huggingface-hub = ["huggingface-hub (>=0.22)"] langchain = ["langchain (>=0.0.210)"] +litestar = ["litestar (>=2.0.0)"] loguru = ["loguru (>=0.5)"] openai = ["openai (>=1.0.0)", "tiktoken (>=0.3.0)"] opentelemetry = ["opentelemetry-distro (>=0.35b0)"] From cfec693bb641e1ec8c2f26267052733e1ea8752c Mon Sep 17 00:00:00 2001 From: thenav56 Date: Tue, 27 Aug 2024 18:57:32 +0545 Subject: [PATCH 39/51] Update Status/TimeEntryType labels --- ...0007_remove_timeentry_is_done_timeentry_status.py | 2 +- apps/track/migrations/0017_alter_timeentry_type.py | 8 ++++---- apps/track/models.py | 12 ++++++------ 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/apps/track/migrations/0007_remove_timeentry_is_done_timeentry_status.py b/apps/track/migrations/0007_remove_timeentry_is_done_timeentry_status.py index eda58fa..fa87d24 100644 --- a/apps/track/migrations/0007_remove_timeentry_is_done_timeentry_status.py +++ b/apps/track/migrations/0007_remove_timeentry_is_done_timeentry_status.py @@ -17,7 +17,7 @@ class Migration(migrations.Migration): migrations.AddField( model_name="timeentry", name="status", - field=models.PositiveSmallIntegerField(choices=[(1, "Doing"), (2, "Done"), (3, "TODO")], default=2), + field=models.PositiveSmallIntegerField(choices=[(1, "DOING"), (2, "DONE"), (3, "TODO")], default=2), preserve_default=False, ), ] diff --git a/apps/track/migrations/0017_alter_timeentry_type.py b/apps/track/migrations/0017_alter_timeentry_type.py index 651afc7..de8d00a 100644 --- a/apps/track/migrations/0017_alter_timeentry_type.py +++ b/apps/track/migrations/0017_alter_timeentry_type.py @@ -56,10 +56,10 @@ class Migration(migrations.Migration): (12, "Testing"), (3, "Development"), (2, "DevOps"), - (4, "Discussion (External)"), - (5, "Discussion (Internal)"), - (7, "Meeting (External)"), - (8, "Meeting (Internal)"), + (4, "Discussion External"), + (5, "Discussion Internal"), + (7, "Meeting External"), + (8, "Meeting Internal"), ], null=True, ), diff --git a/apps/track/models.py b/apps/track/models.py index ca05600..c126682 100644 --- a/apps/track/models.py +++ b/apps/track/models.py @@ -55,15 +55,15 @@ class Type(models.IntegerChoices): # Communication # - Discussion - EXTERNAL_DISCUSSION = 4, _("Discussion (External)") - INTERNAL_DISCUSSION = 5, _("Discussion (Internal)") + EXTERNAL_DISCUSSION = 4, _("Discussion External") + INTERNAL_DISCUSSION = 5, _("Discussion Internal") # - Meeting - EXTERNAL_MEETING = 7, _("Meeting (External)") - INTERNAL_MEETING = 8, _("Meeting (Internal)") + EXTERNAL_MEETING = 7, _("Meeting External") + INTERNAL_MEETING = 8, _("Meeting Internal") class Status(models.IntegerChoices): - DOING = 1, _("Doing") - DONE = 2, _("Done") + DOING = 1, _("DOING") + DONE = 2, _("DONE") TODO = 3, _("TODO") user = models.ForeignKey(User, on_delete=models.PROTECT, related_name="+") From ceb565995ecb06ba9b7ae0669adad1237cb51570 Mon Sep 17 00:00:00 2001 From: thenav56 Date: Wed, 28 Aug 2024 22:48:03 +0545 Subject: [PATCH 40/51] Add PROTECT rule Allow delete for unreferenced rows --- .../migrations/0004_alter_journal_user.py | 23 +++++++++++++++++++ apps/journal/models.py | 2 +- apps/project/admin.py | 10 ++++---- apps/standup/admin.py | 4 ++-- apps/track/admin.py | 11 +++------ 5 files changed, 34 insertions(+), 16 deletions(-) create mode 100644 apps/journal/migrations/0004_alter_journal_user.py diff --git a/apps/journal/migrations/0004_alter_journal_user.py b/apps/journal/migrations/0004_alter_journal_user.py new file mode 100644 index 0000000..1cd6aeb --- /dev/null +++ b/apps/journal/migrations/0004_alter_journal_user.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2.15 on 2024-08-28 17:03 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("journal", "0003_journal_wfh_type"), + ] + + operations = [ + migrations.AlterField( + model_name="journal", + name="user", + field=models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, related_name="+", to=settings.AUTH_USER_MODEL + ), + ), + ] diff --git a/apps/journal/models.py b/apps/journal/models.py index 87702ac..d6c6103 100644 --- a/apps/journal/models.py +++ b/apps/journal/models.py @@ -36,7 +36,7 @@ class WorkFromHomeType(models.IntegerChoices): ] ) - user = models.ForeignKey(User, related_name="+", on_delete=models.CASCADE) + user = models.ForeignKey(User, related_name="+", on_delete=models.PROTECT) date = models.DateField() leave_type = models.PositiveSmallIntegerField(null=True, blank=True, choices=LeaveType.choices) diff --git a/apps/project/admin.py b/apps/project/admin.py index cb9167f..3f6b1ab 100644 --- a/apps/project/admin.py +++ b/apps/project/admin.py @@ -1,26 +1,26 @@ from admin_auto_filters.filters import AutocompleteFilterFactory from django.contrib import admin -from apps.common.admin import PreventDeleteAdminMixin, UserResourceAdmin, VersionAdmin +from apps.common.admin import UserResourceAdmin, VersionAdmin from .models import Client, Contractor, Deadline, Project @admin.register(Client) -class ClientAdmin(PreventDeleteAdminMixin, VersionAdmin, UserResourceAdmin): +class ClientAdmin(VersionAdmin, UserResourceAdmin): search_fields = ("name",) list_display = ("name",) @admin.register(Contractor) -class ContractorAdmin(PreventDeleteAdminMixin, VersionAdmin, UserResourceAdmin): +class ContractorAdmin(VersionAdmin, UserResourceAdmin): search_fields = ("name",) list_display = ("name",) @admin.register(Deadline) -class DeadlineAdmin(PreventDeleteAdminMixin, VersionAdmin, UserResourceAdmin): +class DeadlineAdmin(VersionAdmin, UserResourceAdmin): search_fields = ("name",) list_display = ("name",) @@ -31,7 +31,7 @@ class DeadlineAdmin(PreventDeleteAdminMixin, VersionAdmin, UserResourceAdmin): @admin.register(Project) -class ProjectAdmin(PreventDeleteAdminMixin, VersionAdmin, UserResourceAdmin): +class ProjectAdmin(VersionAdmin, UserResourceAdmin): search_fields = ("name",) list_filter = ( AutocompleteFilterFactory("Client", "project_client"), diff --git a/apps/standup/admin.py b/apps/standup/admin.py index d0e3eee..577874a 100644 --- a/apps/standup/admin.py +++ b/apps/standup/admin.py @@ -1,11 +1,11 @@ from django.contrib import admin -from apps.common.admin import PreventDeleteAdminMixin, UserResourceAdmin, VersionAdmin +from apps.common.admin import UserResourceAdmin, VersionAdmin from .models import Quote @admin.register(Quote) -class QuoteAdmin(PreventDeleteAdminMixin, VersionAdmin, UserResourceAdmin): +class QuoteAdmin(VersionAdmin, UserResourceAdmin): search_fields = ("author",) list_display = ("author",) diff --git a/apps/track/admin.py b/apps/track/admin.py index 4eb56a5..c80a133 100644 --- a/apps/track/admin.py +++ b/apps/track/admin.py @@ -4,12 +4,7 @@ from django.http import HttpRequest from django.utils.translation import ngettext -from apps.common.admin import ( - PreventDeleteAdminMixin, - UserResourceAdmin, - UserResourceTabularInline, - VersionAdmin, -) +from apps.common.admin import UserResourceAdmin, UserResourceTabularInline, VersionAdmin from .models import Contract, Task, TimeEntry @@ -21,7 +16,7 @@ class ContractTaskInline(UserResourceTabularInline): @admin.register(Contract) -class ContractAdmin(PreventDeleteAdminMixin, VersionAdmin, UserResourceAdmin): +class ContractAdmin(VersionAdmin, UserResourceAdmin): search_fields = ( "project__name", "name", @@ -44,7 +39,7 @@ def get_project(self, obj): @admin.register(Task) -class TaskAdmin(PreventDeleteAdminMixin, VersionAdmin, UserResourceAdmin): +class TaskAdmin(VersionAdmin, UserResourceAdmin): search_fields = ("name",) list_filter = ( AutocompleteFilterFactory("Project", "contract__project"), From 45293a95fa3af1d2de3e226c80a08e93b8a8f85d Mon Sep 17 00:00:00 2001 From: thenav56 Date: Wed, 28 Aug 2024 22:57:16 +0545 Subject: [PATCH 41/51] Add partial index for active project/contract/tasks --- apps/common/models.py | 7 ++++ .../0008_alter_project_options_and_more.py | 23 +++++++++++++ apps/project/models.py | 7 ++-- ...act_options_alter_task_options_and_more.py | 33 +++++++++++++++++++ apps/track/models.py | 8 ++++- 5 files changed, 75 insertions(+), 3 deletions(-) create mode 100644 apps/project/migrations/0008_alter_project_options_and_more.py create mode 100644 apps/track/migrations/0018_alter_contract_options_alter_task_options_and_more.py diff --git a/apps/common/models.py b/apps/common/models.py index 79a80c6..c303235 100644 --- a/apps/common/models.py +++ b/apps/common/models.py @@ -34,6 +34,13 @@ class Meta: # type: ignore[reportIncompatibleVariableOverride] ordering = ["-id"] +NotArchivedFilterIndex = models.Index( + fields=["is_archived"], + name="%(app_label)s_%(class)s_active_idx", + condition=models.Q(is_archived=False), +) + + # -- Common models class Event(UserResource): class Type(models.IntegerChoices): diff --git a/apps/project/migrations/0008_alter_project_options_and_more.py b/apps/project/migrations/0008_alter_project_options_and_more.py new file mode 100644 index 0000000..44b8d04 --- /dev/null +++ b/apps/project/migrations/0008_alter_project_options_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2.15 on 2024-08-28 17:10 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("project", "0007_project_logo_hd_alter_project_logo"), + ] + + operations = [ + migrations.AlterModelOptions( + name="project", + options={}, + ), + migrations.AddIndex( + model_name="project", + index=models.Index( + condition=models.Q(("is_archived", False)), fields=["is_archived"], name="project_project_active_idx" + ), + ), + ] diff --git a/apps/project/models.py b/apps/project/models.py index ba3980d..91a9e3a 100644 --- a/apps/project/models.py +++ b/apps/project/models.py @@ -1,7 +1,7 @@ from django.db import models from django.utils.translation import gettext_lazy as _ -from apps.common.models import UserResource +from apps.common.models import NotArchivedFilterIndex, UserResource class Client(UserResource): @@ -50,6 +50,9 @@ class Project(UserResource): project_client_id: int contractor_id: int + class Meta: # type: ignore [reportIncompatibleVariableOverride] + indexes = [NotArchivedFilterIndex] + def __str__(self): return self.name @@ -65,7 +68,7 @@ class Deadline(UserResource): blank=True, ) - is_archived = models.BooleanField(default=False) + is_archived = models.BooleanField(default=False) # XXX: Is this useful? start_date = models.DateField() end_date = models.DateField() diff --git a/apps/track/migrations/0018_alter_contract_options_alter_task_options_and_more.py b/apps/track/migrations/0018_alter_contract_options_alter_task_options_and_more.py new file mode 100644 index 0000000..32a21a4 --- /dev/null +++ b/apps/track/migrations/0018_alter_contract_options_alter_task_options_and_more.py @@ -0,0 +1,33 @@ +# Generated by Django 4.2.15 on 2024-08-28 17:10 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("track", "0017_alter_timeentry_type"), + ] + + operations = [ + migrations.AlterModelOptions( + name="contract", + options={}, + ), + migrations.AlterModelOptions( + name="task", + options={}, + ), + migrations.AddIndex( + model_name="contract", + index=models.Index( + condition=models.Q(("is_archived", False)), fields=["is_archived"], name="track_contract_active_idx" + ), + ), + migrations.AddIndex( + model_name="task", + index=models.Index( + condition=models.Q(("is_archived", False)), fields=["is_archived"], name="track_task_active_idx" + ), + ), + ] diff --git a/apps/track/models.py b/apps/track/models.py index c126682..77ff242 100644 --- a/apps/track/models.py +++ b/apps/track/models.py @@ -2,7 +2,7 @@ from django.db import models from django.utils.translation import gettext_lazy as _ -from apps.common.models import UserResource +from apps.common.models import NotArchivedFilterIndex, UserResource from apps.project.models import Project from apps.user.models import User @@ -18,6 +18,9 @@ class Contract(UserResource): project_id: int tasks: models.QuerySet["Task"] + class Meta: # type: ignore [reportIncompatibleVariableOverride] + indexes = [NotArchivedFilterIndex] + def __str__(self): # NOTE: N+1 return f"{self.project.name} -> {self.name} ({self.total_estimated_hours} hours)" @@ -32,6 +35,9 @@ class Task(UserResource): contract_id: int + class Meta: # type: ignore [reportIncompatibleVariableOverride] + indexes = [NotArchivedFilterIndex] + def __str__(self): return self.name From 155c077e57aa4e82c47401a15790b5e429369ef5 Mon Sep 17 00:00:00 2001 From: thenav56 Date: Wed, 28 Aug 2024 23:32:05 +0545 Subject: [PATCH 42/51] Return working days count for events - Cache event dates using redis --- apps/common/admin.py | 2 +- apps/common/models.py | 73 ++++++++++++++++++++++++++++++++++++------- apps/project/types.py | 10 +++--- main/caches.py | 1 + 4 files changed, 67 insertions(+), 19 deletions(-) diff --git a/apps/common/admin.py b/apps/common/admin.py index 884d9b2..5d8785d 100644 --- a/apps/common/admin.py +++ b/apps/common/admin.py @@ -89,7 +89,7 @@ def get_readonly_fields(self, *args, **kwargs): # -- Common Models @admin.register(Event) -class ClientAdmin(VersionAdmin, UserResourceAdmin): +class EventAdmin(VersionAdmin, UserResourceAdmin): search_fields = ("name",) list_display = ("name", "type", "start_date", "end_date") list_filter = ("type",) diff --git a/apps/common/models.py b/apps/common/models.py index c303235..f8af802 100644 --- a/apps/common/models.py +++ b/apps/common/models.py @@ -1,11 +1,12 @@ import datetime -import functools from asgiref.sync import sync_to_async +from django.core.cache import cache from django.db import models from django.utils import timezone from apps.user.models import User +from main.caches import CacheKey # -- Abstracts @@ -48,30 +49,56 @@ class Type(models.IntegerChoices): RETREAT = 2, "Retreat" MISC = 3, "Misc" + __NON_WORKING__ = [ + HOLIDAY[0], + RETREAT[0], + ] + name = models.CharField(max_length=225) type = models.PositiveSmallIntegerField(choices=Type.choices, default=Type.HOLIDAY) start_date = models.DateField() end_date = models.DateField() + def save(self, *args, **kwargs): + cache.delete(CacheKey.TIMUR_EVENT_DATES) + return super().save(*args, **kwargs) + @staticmethod def is_weekend(date: datetime.date): return date.weekday() > 4 # 5 Sat, 6 Su - def get_dates(self, include_weekends=False) -> list[datetime.date]: - if self.start_date == self.end_date: - if not include_weekends and self.is_weekend(self.start_date): + @classmethod + def generate_dates( + cls, + start_date: datetime.date, + end_date: datetime.date, + include_holidays=False, + include_weekends=False, + ) -> list[datetime.date]: + if start_date == end_date: + if not include_weekends and cls.is_weekend(start_date): return [] - return [self.start_date] + return [start_date] dates = [] - for x in range((self.end_date - self.start_date).days): - date = self.start_date + datetime.timedelta(days=x) - if not include_weekends and self.is_weekend(date): + for x in range((end_date - start_date).days): + date = start_date + datetime.timedelta(days=x) + if not include_weekends and cls.is_weekend(date): + continue + if not include_holidays and date in cls.get_relative_event_dates(): continue dates.append(date) return sorted(set(dates)) + def get_dates(self, include_weekends=False) -> list[datetime.date]: + return self.generate_dates( + self.start_date, + self.end_date, + include_weekends=include_weekends, + include_holidays=True, # Don't care about other holidays (itself included) + ) + @classmethod def get_last_working_date( cls, @@ -112,16 +139,21 @@ def get_relative_events(cls) -> models.QuerySet["Event"]: now = timezone.now().date() start_threshold = now - datetime.timedelta(days=200) end_threshold = now + datetime.timedelta(days=200) - return cls.objects.filter(start_date__gte=start_threshold, end_date__lte=end_threshold) + return cls.objects.filter( + type__in=cls.Type.__NON_WORKING__, + start_date__gte=start_threshold, + end_date__lte=end_threshold, + ) @classmethod - @functools.cache # TODO: URGENT! Clear this cache on events CUD def get_relative_event_dates(cls) -> list[datetime.date]: """ Return list of dates with holiday relative to current date """ - dates = [] + if cached_value := cache.get(CacheKey.TIMUR_EVENT_DATES): + return cached_value + dates = [] qs = cls.get_relative_events() for start_date, end_date in qs.values_list("start_date", "end_date"): if start_date == end_date: @@ -131,4 +163,21 @@ def get_relative_event_dates(cls) -> list[datetime.date]: for x in range((end_date - start_date).days): dates.append(start_date + datetime.timedelta(days=x)) - return sorted(set(dates)) + sorted_dates = sorted(set(dates)) + cache.set(CacheKey.TIMUR_EVENT_DATES, sorted_dates, 3600) # Cache for 1hr + return sorted_dates + + @classmethod + def get_working_days_count(cls, start_date: datetime.date, end_date: datetime.date) -> int: + """ + Return number of working days excluding weekends and holidays + """ + return len(cls.generate_dates(start_date, end_date)) + + @classmethod + @sync_to_async + def aget_working_days_count(cls, start_date: datetime.date, end_date: datetime.date) -> int: + """ + Return number of working days excluding weekends and holidays + """ + return cls.get_working_days_count(start_date, end_date) diff --git a/apps/project/types.py b/apps/project/types.py index f35f5eb..edfe27f 100644 --- a/apps/project/types.py +++ b/apps/project/types.py @@ -3,6 +3,7 @@ from django.db import models from django.utils import timezone +from apps.common.models import Event from apps.common.types import UserResourceTypeMixin from main.graphql.context import Info from utils.common import get_queryset_for_model @@ -49,18 +50,15 @@ def get_queryset(_, queryset: models.QuerySet | None, info: Info): @strawberry_django.field async def total_days(self, root: strawberry.Parent[Deadline]) -> int: - # TODO: Return only working days - return (root.end_date - root.start_date).days + return await Event.aget_working_days_count(root.start_date, root.end_date) @strawberry_django.field async def used_days(self, root: strawberry.Parent[Deadline]) -> int: - # TODO: Return only working days - return (timezone.now().date() - root.start_date).days + return await Event.aget_working_days_count(root.start_date, timezone.now().date()) @strawberry_django.field async def remaining_days(self, root: strawberry.Parent[Deadline]) -> int: - # TODO: Return only working days - return (root.end_date - timezone.now().date()).days + return await Event.aget_working_days_count(timezone.now().date(), root.end_date) @strawberry_django.type(Project) diff --git a/main/caches.py b/main/caches.py index a511503..16e3ec0 100644 --- a/main/caches.py +++ b/main/caches.py @@ -7,6 +7,7 @@ class CacheKey: # Redis Cache + TIMUR_EVENT_DATES = "timur-event-dates" URL_CACHED_FILE_FIELD_KEY_FORMAT = "url-cached-file-key-{0}" # Local (RAM) Cache From b90cd09a47bc7120434a8f1293d32e5ff59e9710 Mon Sep 17 00:00:00 2001 From: thenav56 Date: Wed, 28 Aug 2024 23:32:33 +0545 Subject: [PATCH 43/51] Fix filters - Add users paginated node --- apps/track/filters.py | 21 +++++++++++++++++++-- apps/user/filters.py | 21 +++++++++++++++++++++ apps/user/orders.py | 11 +++++++++++ apps/user/queries.py | 13 +++++++++++-- schema.graphql | 27 ++++++++++++++++++++++++++- 5 files changed, 88 insertions(+), 5 deletions(-) create mode 100644 apps/user/filters.py create mode 100644 apps/user/orders.py diff --git a/apps/track/filters.py b/apps/track/filters.py index 972d36a..afcd755 100644 --- a/apps/track/filters.py +++ b/apps/track/filters.py @@ -2,7 +2,7 @@ import strawberry_django from django.db import models -from .enums import TimeEntryTypeEnum +from .enums import TimeEntryStatusEnum, TimeEntryTypeEnum from .models import Contract, Task, TimeEntry @@ -32,10 +32,18 @@ def project( @strawberry_django.filters.filter(TimeEntry, lookups=True) class TimeEntryFilter: id: strawberry.auto - user: strawberry.auto task: strawberry.auto date: strawberry.auto + @strawberry_django.filter_field + def users( + self, + queryset: models.QuerySet, + value: list[strawberry.ID], # type: ignore[reportInvalidTypeForm] + prefix: str, + ) -> tuple[models.QuerySet, models.Q]: + return queryset, models.Q(**{f"{prefix}user__in": value}) + @strawberry_django.filter_field def types( self, @@ -45,6 +53,15 @@ def types( ) -> tuple[models.QuerySet, models.Q]: return queryset, models.Q(**{f"{prefix}type__in": value}) + @strawberry_django.filter_field + def statuses( + self, + queryset: models.QuerySet, + value: list[TimeEntryStatusEnum], # type: ignore[reportInvalidTypeForm] + prefix: str, + ) -> tuple[models.QuerySet, models.Q]: + return queryset, models.Q(**{f"{prefix}status__in": value}) + @strawberry_django.filter_field def project( self, diff --git a/apps/user/filters.py b/apps/user/filters.py new file mode 100644 index 0000000..d811f19 --- /dev/null +++ b/apps/user/filters.py @@ -0,0 +1,21 @@ +import strawberry +import strawberry_django +from django.db import models + +from .enums import UserDepartmentTypeEnum +from .models import User + + +@strawberry_django.filters.filter(User, lookups=True) +class UserFilter: + id: strawberry.auto + display_name: strawberry.auto + + @strawberry_django.filter_field + def departments( + self, + queryset: models.QuerySet, + value: list[UserDepartmentTypeEnum], # type: ignore[reportInvalidTypeForm] + prefix: str, + ) -> tuple[models.QuerySet, models.Q]: + return queryset, models.Q(**{f"{prefix}department__in": value}) diff --git a/apps/user/orders.py b/apps/user/orders.py new file mode 100644 index 0000000..a3d2288 --- /dev/null +++ b/apps/user/orders.py @@ -0,0 +1,11 @@ +import strawberry +import strawberry_django + +from .models import User + + +@strawberry_django.ordering.order(User) +class UserOrder: + id: strawberry.auto + display_name: strawberry.auto + department: strawberry.auto diff --git a/apps/user/queries.py b/apps/user/queries.py index 1e7fdd5..41f6a2a 100644 --- a/apps/user/queries.py +++ b/apps/user/queries.py @@ -2,8 +2,11 @@ from asgiref.sync import sync_to_async from main.graphql.context import Info +from utils.strawberry.paginations import CountList, pagination_field -from .types import UserMeType +from .filters import UserFilter +from .orders import UserOrder +from .types import UserMeType, UserType @strawberry.type @@ -17,4 +20,10 @@ def me(self, info: Info) -> UserMeType | None: @strawberry.type -class PrivateQuery: ... +class PrivateQuery: + # Paginated ---------------------------- + users: CountList[UserType] = pagination_field( + pagination=True, + filters=UserFilter, + order=UserOrder, + ) diff --git a/schema.graphql b/schema.graphql index dbb9d66..9bc2643 100644 --- a/schema.graphql +++ b/schema.graphql @@ -420,6 +420,7 @@ type PrivateMutation { } type PrivateQuery { + users(filters: UserFilter, order: UserOrder, pagination: OffsetPaginationInput): UserTypeCountList! dailyStandup(date: Date!): DailyStandUpType! clients(filters: ClientFilter, order: ClientOrder, pagination: OffsetPaginationInput): ClientTypeCountList! contractors(filters: ContractorFilter, order: ContractorOrder, pagination: OffsetPaginationInput): ContractorTypeCountList! @@ -631,14 +632,15 @@ input TimeEntryCreateInput { input TimeEntryFilter { id: IDBaseFilterLookup - user: DjangoModelFilterInput task: DjangoModelFilterInput date: DateDateFilterLookup AND: TimeEntryFilter OR: TimeEntryFilter NOT: TimeEntryFilter DISTINCT: Boolean + users: [ID!] types: [TimeEntryTypeEnum!] + statuses: [TimeEntryStatusEnum!] project: ID contract: ID } @@ -738,6 +740,16 @@ enum UserDepartmentTypeEnum { QUALITY_ASSURANCE } +input UserFilter { + id: IDBaseFilterLookup + displayName: StrFilterLookup + AND: UserFilter + OR: UserFilter + NOT: UserFilter + DISTINCT: Boolean + departments: [UserDepartmentTypeEnum!] +} + type UserMeType implements UserBaseType { id: ID! firstName: String @@ -760,6 +772,12 @@ type UserMeTypeMutationResponseType { result: UserMeType } +input UserOrder { + id: Ordering + displayName: Ordering + department: Ordering +} + interface UserResourceTypeMixin { createdAt: DateTime! modifiedAt: DateTime! @@ -777,4 +795,11 @@ type UserType implements UserBaseType { departmentDisplay: String leaveToday: JournalLeaveTypeEnum workFromHomeToday: JournalWorkFromHomeTypeEnum +} + +type UserTypeCountList { + limit: Int! + offset: Int! + count: Int! + items: [UserType!]! } \ No newline at end of file From eb36cb4ef48cd7aad9ca1a952aace0d8d00653eb Mon Sep 17 00:00:00 2001 From: thenav56 Date: Wed, 28 Aug 2024 23:48:34 +0545 Subject: [PATCH 44/51] Use active deadlines only --- apps/journal/models.py | 1 - apps/project/admin.py | 3 ++- apps/project/dataloaders.py | 2 +- .../0009_alter_deadline_options_and_more.py | 23 +++++++++++++++++++ apps/project/models.py | 14 ++++++++++- apps/project/types.py | 6 +++-- schema.graphql | 2 +- 7 files changed, 44 insertions(+), 7 deletions(-) create mode 100644 apps/project/migrations/0009_alter_deadline_options_and_more.py diff --git a/apps/journal/models.py b/apps/journal/models.py index d6c6103..26239a0 100644 --- a/apps/journal/models.py +++ b/apps/journal/models.py @@ -75,7 +75,6 @@ def leave_wfh_check(self): if self.leave_type is not None and self.wfh_type is not None: if (self.leave_type, self.wfh_type) not in self.VALID_LEAVE_WFH_COMBINATION: raise ValidationError(_("Provided Leave and Work from home combination is invalid")) - pass def clean(self): super().clean() diff --git a/apps/project/admin.py b/apps/project/admin.py index 3f6b1ab..0d2b776 100644 --- a/apps/project/admin.py +++ b/apps/project/admin.py @@ -15,7 +15,6 @@ class ClientAdmin(VersionAdmin, UserResourceAdmin): @admin.register(Contractor) class ContractorAdmin(VersionAdmin, UserResourceAdmin): search_fields = ("name",) - list_display = ("name",) @@ -25,6 +24,7 @@ class DeadlineAdmin(VersionAdmin, UserResourceAdmin): list_display = ("name",) list_filter = ( + "is_archived", AutocompleteFilterFactory("Project", "project"), AutocompleteFilterFactory("Contract", "contract"), ) @@ -34,6 +34,7 @@ class DeadlineAdmin(VersionAdmin, UserResourceAdmin): class ProjectAdmin(VersionAdmin, UserResourceAdmin): search_fields = ("name",) list_filter = ( + "is_archived", AutocompleteFilterFactory("Client", "project_client"), AutocompleteFilterFactory("Contractor", "contractor"), ) diff --git a/apps/project/dataloaders.py b/apps/project/dataloaders.py index 76b48d1..a8ff1c1 100644 --- a/apps/project/dataloaders.py +++ b/apps/project/dataloaders.py @@ -26,7 +26,7 @@ def load_project(keys: list[int]) -> list["ProjectType"]: def load_deadlines(keys: list[int]) -> list[list["DeadlineType"]]: - qs = Deadline.objects.filter(project__in=keys) + qs = Deadline.objects.filter(project__in=keys, is_archived=False) _map = defaultdict(list) for obj in qs: _map[obj.project_id].append(obj) diff --git a/apps/project/migrations/0009_alter_deadline_options_and_more.py b/apps/project/migrations/0009_alter_deadline_options_and_more.py new file mode 100644 index 0000000..315454f --- /dev/null +++ b/apps/project/migrations/0009_alter_deadline_options_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2.15 on 2024-08-28 18:02 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("project", "0008_alter_project_options_and_more"), + ] + + operations = [ + migrations.AlterModelOptions( + name="deadline", + options={}, + ), + migrations.AddIndex( + model_name="deadline", + index=models.Index( + condition=models.Q(("is_archived", False)), fields=["is_archived"], name="project_deadline_active_idx" + ), + ), + ] diff --git a/apps/project/models.py b/apps/project/models.py index 91a9e3a..8c18ae2 100644 --- a/apps/project/models.py +++ b/apps/project/models.py @@ -1,3 +1,4 @@ +from django.core.exceptions import ValidationError from django.db import models from django.utils.translation import gettext_lazy as _ @@ -68,8 +69,19 @@ class Deadline(UserResource): blank=True, ) - is_archived = models.BooleanField(default=False) # XXX: Is this useful? + is_archived = models.BooleanField(default=False) start_date = models.DateField() end_date = models.DateField() project_id: int + + class Meta: # type: ignore [reportIncompatibleVariableOverride] + indexes = [NotArchivedFilterIndex] + + def dates_check(self): + if self.start_date > self.end_date: + raise ValidationError(_("Start date can't be greater then End date")) + + def clean(self): + super().clean() + self.dates_check() diff --git a/apps/project/types.py b/apps/project/types.py index edfe27f..0e99e17 100644 --- a/apps/project/types.py +++ b/apps/project/types.py @@ -56,9 +56,11 @@ async def total_days(self, root: strawberry.Parent[Deadline]) -> int: async def used_days(self, root: strawberry.Parent[Deadline]) -> int: return await Event.aget_working_days_count(root.start_date, timezone.now().date()) - @strawberry_django.field + @strawberry_django.field(deprecation_reason="Use total_days - used_days instead") async def remaining_days(self, root: strawberry.Parent[Deadline]) -> int: - return await Event.aget_working_days_count(timezone.now().date(), root.end_date) + total_days = await Event.aget_working_days_count(root.start_date, root.end_date) + used_days = await Event.aget_working_days_count(root.start_date, timezone.now().date()) + return total_days - used_days @strawberry_django.type(Project) diff --git a/schema.graphql b/schema.graphql index 9bc2643..74bea22 100644 --- a/schema.graphql +++ b/schema.graphql @@ -240,7 +240,7 @@ type DeadlineType implements UserResourceTypeMixin { name: String! totalDays: Int! usedDays: Int! - remainingDays: Int! + remainingDays: Int! @deprecated(reason: "Use total_days - used_days instead") } type DjangoImageType { From ff3f35e46667efff0cfe4b9afadbfe3a50775130 Mon Sep 17 00:00:00 2001 From: thenav56 Date: Wed, 28 Aug 2024 23:55:58 +0545 Subject: [PATCH 45/51] Standup (Look for activity to 1 more day) --- apps/standup/types.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/standup/types.py b/apps/standup/types.py index 8353725..4964ed4 100644 --- a/apps/standup/types.py +++ b/apps/standup/types.py @@ -66,7 +66,7 @@ class DailyStandUpProjectStatType: date: strawberry.Private[datetime.date] async def _check_activity_from_date(self) -> datetime.date: - return await Event.aget_last_working_date(now_date=self.date, offset_count=3) + return await Event.aget_last_working_date(now_date=self.date, offset_count=1) @strawberry.field async def last_working_date(self) -> datetime.date: From 9b439cd3f1adec2aab5cbdd091015445f674a5a2 Mon Sep 17 00:00:00 2001 From: thenav56 Date: Tue, 3 Sep 2024 20:42:54 +0545 Subject: [PATCH 46/51] Add remainingDaysTo Start/End for event - Add filters of +-months for events - Add lazy filter values for LAST_WORKING_DAY and TODAY --- .pre-commit-config.yaml | 2 +- apps/common/models.py | 76 +++++++++++++++++++++++++++++++++++------ apps/common/queries.py | 15 ++++++-- apps/common/types.py | 37 +++++++++++++++++++- apps/track/enums.py | 23 +++++++++++++ apps/track/filters.py | 22 +++++++++++- apps/track/queries.py | 22 ++++++++++++ main/graphql/enums.py | 5 ++- main/graphql/schema.py | 13 +++++++ schema.graphql | 10 ++++++ 10 files changed, 208 insertions(+), 17 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index f914675..70ea6b0 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -29,6 +29,6 @@ repos: - id: flake8 - repo: https://github.com/RobertCraigie/pyright-python - rev: v1.1.367 + rev: v1.1.378 hooks: - id: pyright diff --git a/apps/common/models.py b/apps/common/models.py index f8af802..afbff99 100644 --- a/apps/common/models.py +++ b/apps/common/models.py @@ -2,8 +2,10 @@ from asgiref.sync import sync_to_async from django.core.cache import cache +from django.core.exceptions import ValidationError from django.db import models from django.utils import timezone +from django.utils.translation import gettext_lazy as _ from apps.user.models import User from main.caches import CacheKey @@ -60,10 +62,32 @@ class Type(models.IntegerChoices): start_date = models.DateField() end_date = models.DateField() - def save(self, *args, **kwargs): + def __str__(self): + return self.name + + @classmethod + def reload_cache(cls): + # Delete cache.delete(CacheKey.TIMUR_EVENT_DATES) + # Generate cache + cls.get_relative_event_dates() + + def dates_check(self): + if self.start_date > self.end_date: + raise ValidationError(_("Start date can't be greater then End date")) + + def clean(self): + super().clean() + self.dates_check() + + def save(self, *args, **kwargs): + self.reload_cache() return super().save(*args, **kwargs) + def delete(self, *args, **kwargs): + self.reload_cache() + return super().delete(*args, **kwargs) + @staticmethod def is_weekend(date: datetime.date): return date.weekday() > 4 # 5 Sat, 6 Su @@ -82,7 +106,7 @@ def generate_dates( return [start_date] dates = [] - for x in range((end_date - start_date).days): + for x in range((end_date - start_date).days + 1): date = start_date + datetime.timedelta(days=x) if not include_weekends and cls.is_weekend(date): continue @@ -91,12 +115,12 @@ def generate_dates( dates.append(date) return sorted(set(dates)) - def get_dates(self, include_weekends=False) -> list[datetime.date]: + def get_dates(self, include_weekends=False, include_holidays=True) -> list[datetime.date]: return self.generate_dates( self.start_date, self.end_date, include_weekends=include_weekends, - include_holidays=True, # Don't care about other holidays (itself included) + include_holidays=include_holidays, ) @classmethod @@ -131,7 +155,7 @@ def aget_last_working_date( return cls.get_last_working_date(now_date, skip_dates=skip_dates, offset_count=offset_count) @classmethod - def get_relative_events(cls) -> models.QuerySet["Event"]: + def get_relative_non_working_events(cls) -> models.QuerySet["Event"]: """ Return list of dates with holiday relative to current date """ @@ -154,7 +178,7 @@ def get_relative_event_dates(cls) -> list[datetime.date]: return cached_value dates = [] - qs = cls.get_relative_events() + qs = cls.get_relative_non_working_events() for start_date, end_date in qs.values_list("start_date", "end_date"): if start_date == end_date: dates.append(start_date) @@ -168,16 +192,48 @@ def get_relative_event_dates(cls) -> list[datetime.date]: return sorted_dates @classmethod - def get_working_days_count(cls, start_date: datetime.date, end_date: datetime.date) -> int: + @sync_to_async + def aget_relative_event_dates(cls) -> list[datetime.date]: + """ + Return list of dates with holiday relative to current date + """ + return cls.get_relative_event_dates() + + @classmethod + def get_working_days_count( + cls, + start_date: datetime.date, + end_date: datetime.date, + include_weekends: bool = False, + include_holidays: bool = False, + ) -> int: """ Return number of working days excluding weekends and holidays """ - return len(cls.generate_dates(start_date, end_date)) + return len( + cls.generate_dates( + start_date, + end_date, + include_weekends=include_weekends, + include_holidays=include_holidays, + ) + ) @classmethod @sync_to_async - def aget_working_days_count(cls, start_date: datetime.date, end_date: datetime.date) -> int: + def aget_working_days_count( + cls, + start_date: datetime.date, + end_date: datetime.date, + include_weekends: bool = False, + include_holidays: bool = False, + ) -> int: """ Return number of working days excluding weekends and holidays """ - return cls.get_working_days_count(start_date, end_date) + return cls.get_working_days_count( + start_date, + end_date, + include_weekends=include_weekends, + include_holidays=include_holidays, + ) diff --git a/apps/common/queries.py b/apps/common/queries.py index b898f61..415b480 100644 --- a/apps/common/queries.py +++ b/apps/common/queries.py @@ -1,7 +1,9 @@ +import datetime + import strawberry import strawberry_django +from django.utils import timezone -from main.graphql.context import Info from utils.strawberry.paginations import CountList, pagination_field from .filters import EventFilter @@ -21,5 +23,12 @@ class PrivateQuery: # Unbound ---------------------------- @strawberry_django.field - async def relative_events(self, info: Info) -> list[EventType]: - return [event async for event in Event.get_relative_events()] # type: ignore[reportReturnType] + async def relative_events(self) -> list[EventType]: + now = timezone.now().date() + start_threshold = now - datetime.timedelta(days=30) + end_threshold = now + datetime.timedelta(days=30) + qs = Event.objects.filter( + start_date__gte=start_threshold, + end_date__lte=end_threshold, + ) + return [event async for event in qs] # type: ignore[reportReturnType] diff --git a/apps/common/types.py b/apps/common/types.py index cb9c154..277e2cb 100644 --- a/apps/common/types.py +++ b/apps/common/types.py @@ -3,6 +3,7 @@ import strawberry import strawberry_django from django.db import models +from django.utils import timezone from apps.common.serializers import TempClientIdMixin from apps.user.types import UserType @@ -55,4 +56,38 @@ class EventType(UserResourceTypeMixin): @strawberry_django.field def dates(self, event: strawberry.Parent[Event]) -> list[datetime.date]: - return event.get_dates() + # NOTE: include_holidays=True, Don't care about other holidays (itself included) + if event.type in Event.Type.__NON_WORKING__: + return event.get_dates(include_holidays=True) + return event.get_dates(include_holidays=False) + + @strawberry_django.field + def is_active(self, event: strawberry.Parent[Event]) -> bool: + now = timezone.now().date() + return event.start_date <= now <= event.end_date + + @strawberry_django.field + async def remaining_days_to_start(self, event: strawberry.Parent[Event]) -> int: + # XXX: This doesn't work if two non-working events collide + now = timezone.now().date() + if now >= event.start_date: + return 0 + if event.type in Event.Type.__NON_WORKING__: + return await Event.aget_working_days_count( + now, + event.start_date - datetime.timedelta(days=1), + include_holidays=True, + ) + return await Event.aget_working_days_count( + now, + event.start_date - datetime.timedelta(days=1), + include_holidays=False, + ) + + @strawberry_django.field + async def remaining_days_to_end(self, event: strawberry.Parent[Event]) -> int: + # XXX: This doesn't work if two non-working events collide + now = timezone.now().date() + if event.type in Event.Type.__NON_WORKING__: + return await Event.aget_working_days_count(now, event.end_date, include_holidays=True) + return await Event.aget_working_days_count(now, event.end_date, include_holidays=False) diff --git a/apps/track/enums.py b/apps/track/enums.py index f1bc9fa..b2676b5 100644 --- a/apps/track/enums.py +++ b/apps/track/enums.py @@ -1,5 +1,11 @@ +import datetime +import logging +from enum import Enum + import strawberry +from django.utils import timezone +from apps.common.models import Event from utils.strawberry.enums import get_enum_name_from_django_field from .models import TimeEntry @@ -8,6 +14,23 @@ TimeEntryStatusEnum = strawberry.enum(TimeEntry.Status, name="TimeEntryStatusEnum") +logger = logging.getLogger(__name__) + + +@strawberry.enum +class TimeEntryDateFilterEnum(Enum): + LAST_WORKING_DAY = 1 + TODAY = 2 + + @classmethod + def resolve_value(cls, value: "TimeEntryDateFilterEnum") -> datetime.date: + now_date = timezone.now().date() + if value == TimeEntryDateFilterEnum.TODAY: + return now_date + elif value == TimeEntryDateFilterEnum.LAST_WORKING_DAY: + return Event.get_last_working_date(now_date=now_date, offset_count=1) + + enum_map = { get_enum_name_from_django_field(field): enum for field, enum in ( diff --git a/apps/track/filters.py b/apps/track/filters.py index afcd755..8697e9c 100644 --- a/apps/track/filters.py +++ b/apps/track/filters.py @@ -2,7 +2,7 @@ import strawberry_django from django.db import models -from .enums import TimeEntryStatusEnum, TimeEntryTypeEnum +from .enums import TimeEntryDateFilterEnum, TimeEntryStatusEnum, TimeEntryTypeEnum from .models import Contract, Task, TimeEntry @@ -35,6 +35,26 @@ class TimeEntryFilter: task: strawberry.auto date: strawberry.auto + @strawberry_django.filter_field + def date_gte( + self, + queryset: models.QuerySet, + value: TimeEntryDateFilterEnum, # type: ignore[reportInvalidTypeForm] + prefix: str, + ) -> tuple[models.QuerySet, models.Q]: + _value = TimeEntryDateFilterEnum.resolve_value(value) + return queryset, models.Q(**{f"{prefix}date__gte": _value}) + + @strawberry_django.filter_field + def date_lte( + self, + queryset: models.QuerySet, + value: TimeEntryDateFilterEnum, # type: ignore[reportInvalidTypeForm] + prefix: str, + ) -> tuple[models.QuerySet, models.Q]: + _value = TimeEntryDateFilterEnum.resolve_value(value) + return queryset, models.Q(**{f"{prefix}date__lte": _value}) + @strawberry_django.filter_field def users( self, diff --git a/apps/track/queries.py b/apps/track/queries.py index 8d614f7..e51540f 100644 --- a/apps/track/queries.py +++ b/apps/track/queries.py @@ -7,10 +7,29 @@ from main.graphql.context import Info from utils.strawberry.paginations import CountList, pagination_field +# from .models import TimeEntry +# from .enums import TimeEntryDateFilterEnum from .filters import ContractFilter, TaskFilter, TimeEntryFilter from .orders import ContractOrder, TaskOrder, TimeEntryOrder from .types import ContractType, TaskType, TimeEntryType +# from django.db import models + + +# TODO: Remove +# async def custom_time_entries_filters_apply( +# queryset: models.QuerySet[TimeEntry], +# date_gte: TimeEntryDateFilterEnum | None, +# date_lte: TimeEntryDateFilterEnum | None, +# ) -> models.QuerySet: +# if date_gte: +# date_gte_value = await TimeEntryDateFilterEnum.resolve_value(date_gte) +# queryset = queryset.filter(date__gte=date_gte_value) +# if date_lte: +# date_lte_value = await TimeEntryDateFilterEnum.resolve_value(date_lte) +# queryset = queryset.filter(date__lte=date_lte_value) +# return queryset + @strawberry.type class PrivateQuery: @@ -61,9 +80,12 @@ async def all_time_entries( self, info: Info, filters: TimeEntryFilter, + # date_gte: TimeEntryDateFilterEnum | None = None, # type: ignore[reportInvalidTypeForm] + # date_lte: TimeEntryDateFilterEnum | None = None, # type: ignore[reportInvalidTypeForm] ) -> list[TimeEntryType]: queryset = TimeEntryType.get_queryset(None, None, info) queryset = apply_filters(filters, queryset, info, None) + # queryset = await custom_time_entries_filters_apply(queryset, date_gte, date_lte) count = await queryset.acount() if count > 3000: # TODO: Is this fine? raise Exception(f"Try using filters. To much data to return (Row count: {count})") diff --git a/main/graphql/enums.py b/main/graphql/enums.py index ce3857f..84c953d 100644 --- a/main/graphql/enums.py +++ b/main/graphql/enums.py @@ -32,7 +32,10 @@ def generate_app_enum_collection_data(name): return type( name, (), - {field_name: [AppEnumData(e) for e in enum] for field_name, enum in ENUM_TO_STRAWBERRY_ENUM_MAP.items()}, + { + field_name: [AppEnumData(e) for e in enum] # type: ignore[reportGeneralTypeIssues] + for field_name, enum in ENUM_TO_STRAWBERRY_ENUM_MAP.items() + }, ) diff --git a/main/graphql/schema.py b/main/graphql/schema.py index 787b3c2..b14d32b 100644 --- a/main/graphql/schema.py +++ b/main/graphql/schema.py @@ -1,9 +1,11 @@ import strawberry +from django.core.cache import cache from strawberry.django.views import AsyncGraphQLView # Imported to make sure strawberry custom modules are loadded first import utils.strawberry.transformers # pyright: ignore[reportUnusedImport] # type: ignore # noqa F401 from apps.common import queries as common_queries +from apps.common.models import Event from apps.journal import mutations as journal_mutations from apps.journal import queries as journal_queries from apps.project import queries as project_queries @@ -12,6 +14,7 @@ from apps.track import queries as track_queries from apps.user import mutations as user_mutations from apps.user import queries as user_queries +from main.caches import CacheKey from .context import GraphQLContext from .dataloaders import GlobalDataLoader @@ -20,7 +23,17 @@ class CustomAsyncGraphQLView(AsyncGraphQLView): + async def load_event_cache(self): + """ + XXX: This is used by other internal modules + """ + cache.delete(CacheKey.TIMUR_EVENT_DATES) + if not cache.has_key(CacheKey.TIMUR_EVENT_DATES): + # Generate cache + await Event.aget_relative_event_dates() + async def get_context(self, *args, **kwargs) -> GraphQLContext: + await self.load_event_cache() return GraphQLContext( *args, **kwargs, diff --git a/schema.graphql b/schema.graphql index 74bea22..d060bb0 100644 --- a/schema.graphql +++ b/schema.graphql @@ -285,6 +285,9 @@ type EventType implements UserResourceTypeMixin { type: EventTypeEnum! typeDisplay: String! dates: [Date!]! + isActive: Boolean! + remainingDaysToStart: Int! + remainingDaysToEnd: Int! } type EventTypeCountList { @@ -630,6 +633,11 @@ input TimeEntryCreateInput { clientId: ID } +enum TimeEntryDateFilterEnum { + LAST_WORKING_DAY + TODAY +} + input TimeEntryFilter { id: IDBaseFilterLookup task: DjangoModelFilterInput @@ -638,6 +646,8 @@ input TimeEntryFilter { OR: TimeEntryFilter NOT: TimeEntryFilter DISTINCT: Boolean + dateGte: TimeEntryDateFilterEnum + dateLte: TimeEntryDateFilterEnum users: [ID!] types: [TimeEntryTypeEnum!] statuses: [TimeEntryStatusEnum!] From d10bee4431669ad95790ecdeb93167dd6fb2bf1e Mon Sep 17 00:00:00 2001 From: thenav56 Date: Tue, 3 Sep 2024 20:59:37 +0545 Subject: [PATCH 47/51] Admin panel changes --- apps/common/admin.py | 1 + apps/journal/admin.py | 8 +++++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/apps/common/admin.py b/apps/common/admin.py index 5d8785d..1e5128c 100644 --- a/apps/common/admin.py +++ b/apps/common/admin.py @@ -93,3 +93,4 @@ class EventAdmin(VersionAdmin, UserResourceAdmin): search_fields = ("name",) list_display = ("name", "type", "start_date", "end_date") list_filter = ("type",) + ordering = ("start_date",) diff --git a/apps/journal/admin.py b/apps/journal/admin.py index d86173c..bc311df 100644 --- a/apps/journal/admin.py +++ b/apps/journal/admin.py @@ -1,3 +1,4 @@ +from admin_auto_filters.filters import AutocompleteFilterFactory from django.contrib import admin from apps.common.admin import PreventDeleteAdminMixin, VersionAdmin @@ -8,6 +9,11 @@ @admin.register(Journal) class JournalAdmin(PreventDeleteAdminMixin, VersionAdmin): search_fields = ("user",) - list_display = ("user", "date") + list_display = ("user", "date", "leave_type", "wfh_type") + list_filter = (AutocompleteFilterFactory("User", "user"),) + ordering = ( + "date", + "user", + ) autocomplete_fields = ("user",) From f4fffe157c4ccf2b626462677b40816300d715ec Mon Sep 17 00:00:00 2001 From: thenav56 Date: Fri, 6 Sep 2024 07:56:47 +0545 Subject: [PATCH 48/51] Add SQL view for time entries Used to do analysis using SQL by PMs --- .../0019_time_entry_sql_analysis_view.py | 70 +++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 apps/track/migrations/0019_time_entry_sql_analysis_view.py diff --git a/apps/track/migrations/0019_time_entry_sql_analysis_view.py b/apps/track/migrations/0019_time_entry_sql_analysis_view.py new file mode 100644 index 0000000..c0f961b --- /dev/null +++ b/apps/track/migrations/0019_time_entry_sql_analysis_view.py @@ -0,0 +1,70 @@ +# Generated by Django 4.2.15 on 2024-09-06 01:57 + +from django.db import migrations + +CREATE_SQL = """ +CREATE OR REPLACE VIEW time_entry_analysis_query AS + SELECT + te.id, + u.display_name as user_display_name, + u.id as user_id, + te.date, + p.name as project_name, + p.id as project_id, + ct.name as contract_name, + ct.id as contract_id, + CASE + WHEN te.type = 1 THEN 'Design' + WHEN te.type = 2 THEN 'DevOps' + WHEN te.type = 3 THEN 'Development' + WHEN te.type = 6 THEN 'Documentation' + WHEN te.type = 4 THEN 'Discussion - External' + WHEN te.type = 7 THEN 'Meeting - External' + WHEN te.type = 5 THEN 'Discussion - Internal' + WHEN te.type = 8 THEN 'Meeting - Internal' + WHEN te.type = 9 THEN 'Operation' + WHEN te.type = 10 THEN 'Project Management' + WHEN te.type = 11 THEN 'Research' + WHEN te.type = 12 THEN 'Testing' + ELSE te.type::text + END as type_display, + te.description, + t.name as task_name, + t.id as task_id, + te.duration, + te.duration_adjustment, + te.is_billable, + CASE + WHEN te.is_billable THEN + CAST((te.duration + COALESCE(te.duration_adjustment, 0)) as float) / 60 + ELSE 0 + END as hours, + CAST(te.duration as float) / 60 as real_hours, + CASE + WHEN te.status = 1 THEN 'Doing' + WHEN te.status = 2 THEN 'Done' + WHEN te.status = 3 THEN 'Todo' + ELSE 'N/A' + END as status_display + FROM track_timeentry te + LEFT JOIN user_user u ON te.user_id = u.id + LEFT JOIN track_task t ON te.task_id = t.id + LEFT JOIN track_contract ct ON t.contract_id = ct.id + LEFT JOIN project_project p ON ct.project_id = p.id; +""" + +DROP_SQL = "DROP VIEW IF EXISTS time_entry_analysis_query;" + + +class Migration(migrations.Migration): + + dependencies = [ + ("track", "0018_alter_contract_options_alter_task_options_and_more"), + ] + + operations = [ + migrations.RunSQL( + sql=CREATE_SQL, + reverse_sql=DROP_SQL, + ), + ] From c081ef6bdd20cf342288c3608dce6be1d3909a8e Mon Sep 17 00:00:00 2001 From: thenav56 Date: Mon, 9 Sep 2024 21:33:12 +0545 Subject: [PATCH 49/51] Proper handle of Deadline start_date/end_date logic - Hide past events - Hide future deadlines (looking at start_date) - Add warning in admin panel --- apps/common/admin.py | 2 +- apps/common/queries.py | 9 ++++---- apps/project/admin.py | 17 ++++++++++++-- apps/project/dataloaders.py | 8 ++++++- ...line_end_date_alter_deadline_start_date.py | 23 +++++++++++++++++++ apps/project/models.py | 4 ++-- 6 files changed, 53 insertions(+), 10 deletions(-) create mode 100644 apps/project/migrations/0010_alter_deadline_end_date_alter_deadline_start_date.py diff --git a/apps/common/admin.py b/apps/common/admin.py index 1e5128c..abd2062 100644 --- a/apps/common/admin.py +++ b/apps/common/admin.py @@ -50,7 +50,7 @@ def save_model(self, request, obj, form, change): if not change: obj.created_by = request.user obj.modified_by = request.user - return super().save_model(request, obj, form, change) # type: ignore[reportAttributeAccessIssue] + super().save_model(request, obj, form, change) # type: ignore[reportAttributeAccessIssue] def save_formset(self, request, form, formset, change): if not issubclass(formset.model, UserResource): diff --git a/apps/common/queries.py b/apps/common/queries.py index 415b480..c4cceb8 100644 --- a/apps/common/queries.py +++ b/apps/common/queries.py @@ -25,10 +25,11 @@ class PrivateQuery: @strawberry_django.field async def relative_events(self) -> list[EventType]: now = timezone.now().date() - start_threshold = now - datetime.timedelta(days=30) - end_threshold = now + datetime.timedelta(days=30) + threshold = now + datetime.timedelta(days=30) qs = Event.objects.filter( - start_date__gte=start_threshold, - end_date__lte=end_threshold, + # Upcoming events using threshold + start_date__lte=threshold, + # Hide past events + end_date__gte=now, ) return [event async for event in qs] # type: ignore[reportReturnType] diff --git a/apps/project/admin.py b/apps/project/admin.py index 0d2b776..5a46ea9 100644 --- a/apps/project/admin.py +++ b/apps/project/admin.py @@ -1,5 +1,7 @@ from admin_auto_filters.filters import AutocompleteFilterFactory -from django.contrib import admin +from django.contrib import admin, messages +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ from apps.common.admin import UserResourceAdmin, VersionAdmin @@ -22,13 +24,24 @@ class ContractorAdmin(VersionAdmin, UserResourceAdmin): class DeadlineAdmin(VersionAdmin, UserResourceAdmin): search_fields = ("name",) - list_display = ("name",) + list_display = ("name", "start_date", "end_date") list_filter = ( "is_archived", AutocompleteFilterFactory("Project", "project"), AutocompleteFilterFactory("Contract", "contract"), ) + def save_model(self, request, obj, form, change): + super().save_model(request, obj, form, change) + if obj.start_date > timezone.now().date(): + messages.warning( + request, + _( + "It appears that the start date is in the future. " + "This deadline will remain hidden until the start date is reached." + ), + ) + @admin.register(Project) class ProjectAdmin(VersionAdmin, UserResourceAdmin): diff --git a/apps/project/dataloaders.py b/apps/project/dataloaders.py index a8ff1c1..6600f58 100644 --- a/apps/project/dataloaders.py +++ b/apps/project/dataloaders.py @@ -2,6 +2,7 @@ from collections import defaultdict from asgiref.sync import sync_to_async +from django.utils import timezone from django.utils.functional import cached_property from strawberry.dataloader import DataLoader @@ -26,7 +27,12 @@ def load_project(keys: list[int]) -> list["ProjectType"]: def load_deadlines(keys: list[int]) -> list[list["DeadlineType"]]: - qs = Deadline.objects.filter(project__in=keys, is_archived=False) + qs = Deadline.objects.filter( + project__in=keys, + is_archived=False, + # Hide not started deadlines + start_date__lte=timezone.now().date(), + ) _map = defaultdict(list) for obj in qs: _map[obj.project_id].append(obj) diff --git a/apps/project/migrations/0010_alter_deadline_end_date_alter_deadline_start_date.py b/apps/project/migrations/0010_alter_deadline_end_date_alter_deadline_start_date.py new file mode 100644 index 0000000..9106ce0 --- /dev/null +++ b/apps/project/migrations/0010_alter_deadline_end_date_alter_deadline_start_date.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2.15 on 2024-09-09 15:46 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("project", "0009_alter_deadline_options_and_more"), + ] + + operations = [ + migrations.AlterField( + model_name="deadline", + name="end_date", + field=models.DateField(help_text="This will be the date on which we need to deliver the work."), + ), + migrations.AlterField( + model_name="deadline", + name="start_date", + field=models.DateField(help_text="This will be the date from which we need to start working."), + ), + ] diff --git a/apps/project/models.py b/apps/project/models.py index 8c18ae2..81f619a 100644 --- a/apps/project/models.py +++ b/apps/project/models.py @@ -70,8 +70,8 @@ class Deadline(UserResource): ) is_archived = models.BooleanField(default=False) - start_date = models.DateField() - end_date = models.DateField() + start_date = models.DateField(help_text=_("This will be the date from which we need to start working.")) + end_date = models.DateField(help_text=_("This will be the date on which we need to deliver the work.")) project_id: int From 3ee4731e736b21e6b2d9005a99566fbd14d91cba Mon Sep 17 00:00:00 2001 From: thenav56 Date: Tue, 17 Sep 2024 14:53:18 +0545 Subject: [PATCH 50/51] Make SESSION_COOKIE_AGE configurable Using Django's default value: https://docs.djangoproject.com/en/4.2/ref/settings/#std:setting-SESSION_COOKIE_AGE --- main/settings.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/main/settings.py b/main/settings.py index c74427c..587c2a1 100644 --- a/main/settings.py +++ b/main/settings.py @@ -63,6 +63,7 @@ APP_FRONTEND_HOST=str, # http://frontend.example.com DJANGO_ALLOWED_HOST=(list, ["*"]), SESSION_COOKIE_DOMAIN=str, + SESSION_COOKIE_AGE=(int, 1209600), # seconds (Default: 2 weeks) CSRF_COOKIE_DOMAIN=str, # Misc RELEASE=(str, "develop"), @@ -382,6 +383,7 @@ # https://docs.djangoproject.com/en/3.2/ref/settings/#std:setting-SESSION_COOKIE_DOMAIN SESSION_COOKIE_DOMAIN = env("SESSION_COOKIE_DOMAIN") +SESSION_COOKIE_AGE = env("SESSION_COOKIE_AGE") # https://docs.djangoproject.com/en/3.2/ref/settings/#csrf-cookie-domain CSRF_COOKIE_DOMAIN = env("CSRF_COOKIE_DOMAIN") From 14cbb6f32a74daf44b5c3601c25dd24df1bb830a Mon Sep 17 00:00:00 2001 From: thenav56 Date: Sat, 21 Sep 2024 13:22:22 +0545 Subject: [PATCH 51/51] Add proper date filter in admin panel --- apps/journal/admin.py | 8 +++++++- apps/track/admin.py | 11 ++++++++++- main/settings.py | 1 + poetry.lock | 13 ++++++++++++- pyproject.toml | 1 + 5 files changed, 31 insertions(+), 3 deletions(-) diff --git a/apps/journal/admin.py b/apps/journal/admin.py index bc311df..957fd04 100644 --- a/apps/journal/admin.py +++ b/apps/journal/admin.py @@ -1,5 +1,6 @@ from admin_auto_filters.filters import AutocompleteFilterFactory from django.contrib import admin +from rangefilter.filters import DateRangeQuickSelectListFilterBuilder from apps.common.admin import PreventDeleteAdminMixin, VersionAdmin @@ -10,7 +11,12 @@ class JournalAdmin(PreventDeleteAdminMixin, VersionAdmin): search_fields = ("user",) list_display = ("user", "date", "leave_type", "wfh_type") - list_filter = (AutocompleteFilterFactory("User", "user"),) + list_filter = ( + ("date", DateRangeQuickSelectListFilterBuilder()), + AutocompleteFilterFactory("User", "user"), + "leave_type", + "wfh_type", + ) ordering = ( "date", "user", diff --git a/apps/track/admin.py b/apps/track/admin.py index c80a133..86c6ed4 100644 --- a/apps/track/admin.py +++ b/apps/track/admin.py @@ -3,6 +3,7 @@ from django.db import models from django.http import HttpRequest from django.utils.translation import ngettext +from rangefilter.filters import DateRangeQuickSelectListFilterBuilder from apps.common.admin import UserResourceAdmin, UserResourceTabularInline, VersionAdmin @@ -96,7 +97,7 @@ def flag_as_billable(modeladmin, request, queryset): @admin.register(TimeEntry) class TimeEntryAdmin(admin.ModelAdmin): list_filter = ( - "date", + ("date", DateRangeQuickSelectListFilterBuilder()), "type", "status", "is_billable", @@ -116,6 +117,7 @@ class TimeEntryAdmin(admin.ModelAdmin): "get_user", "type", "date", + "get_description_preview", "duration", "duration_adjustment", "is_billable", @@ -141,3 +143,10 @@ def get_task(self, obj): @admin.display(ordering="user__name", description="User") def get_user(self, obj): return obj.user + + @admin.display(ordering="description_preview", description="Description") + def get_description_preview(self, obj): + text = obj.description + if text is None or len(text) < 100: + return text + return text[:100] + "..." diff --git a/main/settings.py b/main/settings.py index 587c2a1..9a0d99e 100644 --- a/main/settings.py +++ b/main/settings.py @@ -131,6 +131,7 @@ "django_premailer", "storages", "corsheaders", + "rangefilter", # Django admin date range filter # - Health-check "health_check", # required "health_check.db", # stock Django health checkers diff --git a/poetry.lock b/poetry.lock index ac6577e..1a64eba 100644 --- a/poetry.lock +++ b/poetry.lock @@ -634,6 +634,17 @@ files = [ [package.dependencies] django = ">=2.0" +[[package]] +name = "django-admin-rangefilter" +version = "0.13.2" +description = "django-admin-rangefilter app, add the filter by a custom date range on the admin UI." +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" +files = [ + {file = "django_admin_rangefilter-0.13.2-py2.py3-none-any.whl", hash = "sha256:012e4bf28790344db5b63a57b814a3d4ae8f7cd8692854288bd3c396515aa761"}, + {file = "django_admin_rangefilter-0.13.2.tar.gz", hash = "sha256:12750c32c01d6cc891ba7d05267ce9921527042084da0ac9548a3ae8109f90d3"}, +] + [[package]] name = "django-cors-headers" version = "4.4.0" @@ -2345,4 +2356,4 @@ files = [ [metadata] lock-version = "2.0" python-versions = "^3.12" -content-hash = "65e3a281b1e0649765a28715674de4c21d898f058736f7d02f567c5fbb82dd15" +content-hash = "2cea9cea1eafd72380fef5480d355bd338fc1177c8f9893c656af8d24986e05d" diff --git a/pyproject.toml b/pyproject.toml index a918422..8c5b5d4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,6 +34,7 @@ google-api-python-client = "*" django-health-check = "*" Pillow = "*" # Required by django ImageField psutil = "*" # Required by django-health-check +django-admin-rangefilter = "*" [tool.poetry.dev-dependencies] dacite = "*"