diff --git a/.aws/task-definition.json b/.aws/task-definition.json index fab0b6f2..2cc01962 100644 --- a/.aws/task-definition.json +++ b/.aws/task-definition.json @@ -1,167 +1,167 @@ -{ - "taskDefinitionArn": "arn:aws:ecs:us-east-1:768512802988:task-definition/appointments-definition:24", - "containerDefinitions": [ - { - "name": "backend", - "image": "backend-latest", - "cpu": 0, - "portMappings": [ - { - "name": "backend-5000-tcp", - "containerPort": 5000, - "hostPort": 5000, - "protocol": "tcp", - "appProtocol": "http" - } - ], - "essential": true, - "environment": [ - { - "name": "FRONTEND_URL", - "value": "https://stage.appointment.day" - }, - { - "name": "SHORT_BASE_URL", - "value": "https://stage.apmt.day" - }, - { - "name": "TIER_BASIC_CALENDAR_LIMIT", - "value": "3" - }, - { - "name": "TIER_PLUS_CALENDAR_LIMIT", - "value": "5" - }, - { - "name": "TIER_PRO_CALENDAR_LIMIT", - "value": "10" - }, - { - "name": "LOG_USE_STREAM", - "value": "True" - }, - { - "name": "LOG_LEVEL", - "value": "INFO" - }, - { - "name": "APP_ENV", - "value": "stage" - }, - { - "name": "SENTRY_DSN", - "value": "https://5dddca3ecc964284bb8008bc2beef808@o4505428107853824.ingest.sentry.io/4505428124827648" - } - ], - "secrets": [ - { - "name": "DATABASE_SECRETS", - "valueFrom": "arn:aws:secretsmanager:us-east-1:768512802988:secret:staging/appointment/db-mysql-Ixf6qD" - }, - { - "name": "AUTH0_SECRETS", - "valueFrom": "arn:aws:secretsmanager:us-east-1:768512802988:secret:staging/appointment/auth0-0gzF6d" - }, - { - "name": "DB_ENC_SECRET", - "valueFrom": "arn:aws:secretsmanager:us-east-1:768512802988:secret:staging/appointment/db-secret-CYKglI" - }, - { - "name": "SMTP_SECRETS", - "valueFrom": "arn:aws:secretsmanager:us-east-1:768512802988:secret:staging/appointment/socketlabs-UYmjaC" - }, - { - "name": "GOOGLE_OAUTH_SECRETS", - "valueFrom": "arn:aws:secretsmanager:us-east-1:768512802988:secret:staging/appointment/google-cal-oauth-VevaSo" - } - ], - "mountPoints": [], - "volumesFrom": [], - "logConfiguration": { - "logDriver": "awslogs", - "options": { - "awslogs-create-group": "true", - "awslogs-group": "/ecs/appointments-definition", - "awslogs-region": "us-east-1", - "awslogs-stream-prefix": "ecs" - } - } - }, - { - "name": "frontend", - "image": "frontend-latest", - "cpu": 0, - "portMappings": [ - { - "name": "frontend-80-tcp", - "containerPort": 80, - "hostPort": 80, - "protocol": "tcp", - "appProtocol": "http" - } - ], - "essential": true, - "environment": [], - "mountPoints": [], - "volumesFrom": [], - "logConfiguration": { - "logDriver": "awslogs", - "options": { - "awslogs-create-group": "true", - "awslogs-group": "/ecs/appointments-definition", - "awslogs-region": "us-east-1", - "awslogs-stream-prefix": "ecs" - } - } - } - ], - "family": "appointments-definition", - "executionRoleArn": "arn:aws:iam::768512802988:role/apointments-ci-role", - "networkMode": "awsvpc", - "revision": 24, - "volumes": [], - "status": "ACTIVE", - "requiresAttributes": [ - { - "name": "com.amazonaws.ecs.capability.logging-driver.awslogs" - }, - { - "name": "ecs.capability.execution-role-awslogs" - }, - { - "name": "com.amazonaws.ecs.capability.ecr-auth" - }, - { - "name": "com.amazonaws.ecs.capability.docker-remote-api.1.19" - }, - { - "name": "ecs.capability.execution-role-ecr-pull" - }, - { - "name": "com.amazonaws.ecs.capability.docker-remote-api.1.18" - }, - { - "name": "ecs.capability.task-eni" - }, - { - "name": "com.amazonaws.ecs.capability.docker-remote-api.1.29" - } - ], - "placementConstraints": [], - "compatibilities": [ - "EC2", - "FARGATE" - ], - "requiresCompatibilities": [ - "FARGATE" - ], - "cpu": "512", - "memory": "1024", - "runtimePlatform": { - "cpuArchitecture": "X86_64", - "operatingSystemFamily": "LINUX" - }, - "registeredAt": "2023-03-15T22:19:59.642Z", - "registeredBy": "arn:aws:iam::768512802988:user/melissa", - "tags": [] +{ + "taskDefinitionArn": "arn:aws:ecs:us-east-1:768512802988:task-definition/appointments-definition:24", + "containerDefinitions": [ + { + "name": "backend", + "image": "backend-latest", + "cpu": 0, + "portMappings": [ + { + "name": "backend-5000-tcp", + "containerPort": 5000, + "hostPort": 5000, + "protocol": "tcp", + "appProtocol": "http" + } + ], + "essential": true, + "environment": [ + { + "name": "FRONTEND_URL", + "value": "https://stage.appointment.day" + }, + { + "name": "SHORT_BASE_URL", + "value": "https://stage.apmt.day" + }, + { + "name": "TIER_BASIC_CALENDAR_LIMIT", + "value": "3" + }, + { + "name": "TIER_PLUS_CALENDAR_LIMIT", + "value": "5" + }, + { + "name": "TIER_PRO_CALENDAR_LIMIT", + "value": "10" + }, + { + "name": "LOG_USE_STREAM", + "value": "True" + }, + { + "name": "LOG_LEVEL", + "value": "INFO" + }, + { + "name": "APP_ENV", + "value": "stage" + }, + { + "name": "SENTRY_DSN", + "value": "https://5dddca3ecc964284bb8008bc2beef808@o4505428107853824.ingest.sentry.io/4505428124827648" + } + ], + "secrets": [ + { + "name": "DATABASE_SECRETS", + "valueFrom": "arn:aws:secretsmanager:us-east-1:768512802988:secret:staging/appointment/db-mysql-Ixf6qD" + }, + { + "name": "AUTH0_SECRETS", + "valueFrom": "arn:aws:secretsmanager:us-east-1:768512802988:secret:staging/appointment/auth0-0gzF6d" + }, + { + "name": "DB_ENC_SECRET", + "valueFrom": "arn:aws:secretsmanager:us-east-1:768512802988:secret:staging/appointment/db-secret-CYKglI" + }, + { + "name": "SMTP_SECRETS", + "valueFrom": "arn:aws:secretsmanager:us-east-1:768512802988:secret:staging/appointment/socketlabs-UYmjaC" + }, + { + "name": "GOOGLE_OAUTH_SECRETS", + "valueFrom": "arn:aws:secretsmanager:us-east-1:768512802988:secret:staging/appointment/google-cal-oauth-VevaSo" + } + ], + "mountPoints": [], + "volumesFrom": [], + "logConfiguration": { + "logDriver": "awslogs", + "options": { + "awslogs-create-group": "true", + "awslogs-group": "/ecs/appointments-definition", + "awslogs-region": "us-east-1", + "awslogs-stream-prefix": "ecs" + } + } + }, + { + "name": "frontend", + "image": "frontend-latest", + "cpu": 0, + "portMappings": [ + { + "name": "frontend-80-tcp", + "containerPort": 80, + "hostPort": 80, + "protocol": "tcp", + "appProtocol": "http" + } + ], + "essential": true, + "environment": [], + "mountPoints": [], + "volumesFrom": [], + "logConfiguration": { + "logDriver": "awslogs", + "options": { + "awslogs-create-group": "true", + "awslogs-group": "/ecs/appointments-definition", + "awslogs-region": "us-east-1", + "awslogs-stream-prefix": "ecs" + } + } + } + ], + "family": "appointments-definition", + "executionRoleArn": "arn:aws:iam::768512802988:role/apointments-ci-role", + "networkMode": "awsvpc", + "revision": 24, + "volumes": [], + "status": "ACTIVE", + "requiresAttributes": [ + { + "name": "com.amazonaws.ecs.capability.logging-driver.awslogs" + }, + { + "name": "ecs.capability.execution-role-awslogs" + }, + { + "name": "com.amazonaws.ecs.capability.ecr-auth" + }, + { + "name": "com.amazonaws.ecs.capability.docker-remote-api.1.19" + }, + { + "name": "ecs.capability.execution-role-ecr-pull" + }, + { + "name": "com.amazonaws.ecs.capability.docker-remote-api.1.18" + }, + { + "name": "ecs.capability.task-eni" + }, + { + "name": "com.amazonaws.ecs.capability.docker-remote-api.1.29" + } + ], + "placementConstraints": [], + "compatibilities": [ + "EC2", + "FARGATE" + ], + "requiresCompatibilities": [ + "FARGATE" + ], + "cpu": "512", + "memory": "1024", + "runtimePlatform": { + "cpuArchitecture": "X86_64", + "operatingSystemFamily": "LINUX" + }, + "registeredAt": "2023-03-15T22:19:59.642Z", + "registeredBy": "arn:aws:iam::768512802988:user/melissa", + "tags": [] } \ No newline at end of file diff --git a/.editorconfig b/.editorconfig index a3dff5a5..8e378874 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,15 +1,15 @@ -root = true - -[*] -end_of_line = lf -insert_final_newline = true - -[*.py] -charset = utf-8 -indent_style = space -indent_size = 4 - -[*.{vue,html,css,js,json,yml}] -charset = utf-8 -indent_style = space -indent_size = 2 +root = true + +[*] +end_of_line = lf +insert_final_newline = true + +[*.py] +charset = utf-8 +indent_style = space +indent_size = 4 + +[*.{vue,html,css,js,json,yml}] +charset = utf-8 +indent_style = space +indent_size = 2 diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index ca08eaa4..96c7e601 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -1,31 +1,31 @@ ---- -name: Bug report -labels: bug -about: Create a report to help us improve - ---- - -**Describe the bug** -A clear and concise description of what the bug is. - -**To Reproduce** -Steps to reproduce the behavior: - -1. Go to '...' -2. Click on '....' -3. Scroll down to '....' -4. See error - -**Expected behavior** -A clear and concise description of what you expected to happen. - -**Screenshots** -If applicable, add screenshots to help explain your problem. - -**System (please complete the following information):** - -- OS: [e.g. Windows 10] -- Browser Version [e.g. Firefox 114.0b8 (64-Bit)] - -**Additional context** -Add any other context about the problem here. +--- +name: Bug report +labels: bug +about: Create a report to help us improve + +--- + +**Describe the bug** +A clear and concise description of what the bug is. + +**To Reproduce** +Steps to reproduce the behavior: + +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Screenshots** +If applicable, add screenshots to help explain your problem. + +**System (please complete the following information):** + +- OS: [e.g. Windows 10] +- Browser Version [e.g. Firefox 114.0b8 (64-Bit)] + +**Additional context** +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 3ba13e0c..ac2e8b47 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1 +1 @@ -blank_issues_enabled: false +blank_issues_enabled: false diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md index d3b8683a..cfedea62 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -1,18 +1,18 @@ ---- -name: Feature request -labels: enhancement -about: Suggest an idea for this project - ---- - -**Is your feature request related to a problem? Please describe.** -A clear and concise description of what the problem is. - -**Describe the solution you'd like** -A clear and concise description of what you want to happen. - -**Describe alternatives you've considered** -A clear and concise description of any alternative solutions or features you've considered. - -**Additional context** -Add any other context or screenshots about the feature request here. +--- +name: Feature request +labels: enhancement +about: Suggest an idea for this project + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 7ddc6c56..d0c67874 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,16 +1,16 @@ - - -## Description of the Change - - - -## Benefits - - - -## Applicable Issues - - + + +## Description of the Change + + + +## Benefits + + + +## Applicable Issues + + diff --git a/.github/workflows/aws.yml b/.github/workflows/aws.yml index 28a11ead..61582104 100644 --- a/.github/workflows/aws.yml +++ b/.github/workflows/aws.yml @@ -1,99 +1,99 @@ -# This workflow will build and push a new container image to Amazon ECR, -# and then will deploy a new task definition to Amazon ECS, when there is a push to the "staging" branch. - -name: Deploy to Stage Environment - -# Stop any pending jobs -concurrency: - group: staging - cancel-in-progress: true - -on: - push: - branches: [ "stage" ] - -env: - AWS_REGION: us-east-1 - ECR_REPOSITORY: appointments - ECS_SERVICE: appointments-service - ECS_CLUSTER: appointments - ECS_TASK_DEFINITION: .aws/task-definition.json - - CONTAINER_FRONTEND: frontend - CONTAINER_BACKEND: backend - -permissions: - contents: read - -jobs: - deploy: - name: Build & Deploy - runs-on: ubuntu-latest - environment: - name: staging - url: https://stage.appointment.day - - steps: - - name: Checkout - uses: actions/checkout@v3 - - - name: Configure AWS credentials - uses: aws-actions/configure-aws-credentials@v1 - with: - aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} - aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} - aws-region: ${{ env.AWS_REGION }} - - - name: Login to Amazon ECR - id: login-ecr - uses: aws-actions/amazon-ecr-login@v1 - - - name: Build, tag, and push backend to Amazon ECR - id: build-backend - env: - ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }} - IMAGE_TAG: backend-${{ github.sha }} - run: | - # Build a docker container and - # push it to ECR so that it can - # be deployed to ECS. - docker build -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG ./backend -f ./backend/deploy.dockerfile - docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG - echo "image_backend=$ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG" >> $GITHUB_OUTPUT - - - name: Build, tag, and push frontend to Amazon ECR - id: build-frontend - env: - ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }} - IMAGE_TAG: frontend-${{ github.sha }} - run: | - # Build a docker container and - # push it to ECR so that it can - # be deployed to ECS. - docker build -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG ./frontend -f ./frontend/deploy.dockerfile - docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG - echo "image_frontend=$ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG" >> $GITHUB_OUTPUT - - - name: Fill in the new backend image ID in the Amazon ECS task definition - id: task-def-backend - uses: aws-actions/amazon-ecs-render-task-definition@v1 - with: - task-definition: ${{ env.ECS_TASK_DEFINITION }} - container-name: ${{ env.CONTAINER_BACKEND }} - image: ${{ steps.build-backend.outputs.image_backend }} - - - name: Fill in the new frontend image ID in the Amazon ECS task definition - id: task-def-frontend - uses: aws-actions/amazon-ecs-render-task-definition@v1 - with: - task-definition: ${{ steps.task-def-backend.outputs.task-definition }} - container-name: ${{ env.CONTAINER_FRONTEND }} - image: ${{ steps.build-frontend.outputs.image_frontend }} - - - name: Deploy Amazon ECS task definition - uses: aws-actions/amazon-ecs-deploy-task-definition@v1 - with: - task-definition: ${{ steps.task-def-frontend.outputs.task-definition }} - service: ${{ env.ECS_SERVICE }} - cluster: ${{ env.ECS_CLUSTER }} - wait-for-service-stability: true +# This workflow will build and push a new container image to Amazon ECR, +# and then will deploy a new task definition to Amazon ECS, when there is a push to the "staging" branch. + +name: Deploy to Stage Environment + +# Stop any pending jobs +concurrency: + group: staging + cancel-in-progress: true + +on: + push: + branches: [ "stage" ] + +env: + AWS_REGION: us-east-1 + ECR_REPOSITORY: appointments + ECS_SERVICE: appointments-service + ECS_CLUSTER: appointments + ECS_TASK_DEFINITION: .aws/task-definition.json + + CONTAINER_FRONTEND: frontend + CONTAINER_BACKEND: backend + +permissions: + contents: read + +jobs: + deploy: + name: Build & Deploy + runs-on: ubuntu-latest + environment: + name: staging + url: https://stage.appointment.day + + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v1 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: ${{ env.AWS_REGION }} + + - name: Login to Amazon ECR + id: login-ecr + uses: aws-actions/amazon-ecr-login@v1 + + - name: Build, tag, and push backend to Amazon ECR + id: build-backend + env: + ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }} + IMAGE_TAG: backend-${{ github.sha }} + run: | + # Build a docker container and + # push it to ECR so that it can + # be deployed to ECS. + docker build -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG ./backend -f ./backend/deploy.dockerfile + docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG + echo "image_backend=$ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG" >> $GITHUB_OUTPUT + + - name: Build, tag, and push frontend to Amazon ECR + id: build-frontend + env: + ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }} + IMAGE_TAG: frontend-${{ github.sha }} + run: | + # Build a docker container and + # push it to ECR so that it can + # be deployed to ECS. + docker build -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG ./frontend -f ./frontend/deploy.dockerfile + docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG + echo "image_frontend=$ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG" >> $GITHUB_OUTPUT + + - name: Fill in the new backend image ID in the Amazon ECS task definition + id: task-def-backend + uses: aws-actions/amazon-ecs-render-task-definition@v1 + with: + task-definition: ${{ env.ECS_TASK_DEFINITION }} + container-name: ${{ env.CONTAINER_BACKEND }} + image: ${{ steps.build-backend.outputs.image_backend }} + + - name: Fill in the new frontend image ID in the Amazon ECS task definition + id: task-def-frontend + uses: aws-actions/amazon-ecs-render-task-definition@v1 + with: + task-definition: ${{ steps.task-def-backend.outputs.task-definition }} + container-name: ${{ env.CONTAINER_FRONTEND }} + image: ${{ steps.build-frontend.outputs.image_frontend }} + + - name: Deploy Amazon ECS task definition + uses: aws-actions/amazon-ecs-deploy-task-definition@v1 + with: + task-definition: ${{ steps.task-def-frontend.outputs.task-definition }} + service: ${{ env.ECS_SERVICE }} + cluster: ${{ env.ECS_CLUSTER }} + wait-for-service-stability: true diff --git a/README.md b/README.md old mode 100755 new mode 100644 index 49fb8434..89ff6bd0 --- a/README.md +++ b/README.md @@ -1,124 +1,124 @@ -# Thunderbird Appointment - -Invite others to grab times on your calendar. Choose a date. Make appointments as easy as it gets. - -## Get started - -You can either build preconfigured docker containers (database, backend and frontend) or manually set up the application. A more detailed documentation can befound in the [docs folder](./docs/README.md). - -### With Docker - -```bash -git clone https://github.com/thundernest/appointment -cd appointment -docker-compose up -d --build -``` - -A MySQL database will be accessible via `localhost:3306` with username and password set to: `tba` - -To init database or run migrations, the backend offers a cimple CLI interface: - -```bash -run-command update-db -``` - -### Manual Setup - -Make sure to have the following prerequisites available: - -```plain -Python >= 3.10 -Node.js >= 16.0 -``` - -Run application for development with hot reloading backend and frontend: - -1. Get the application data - - ```bash - git clone https://github.com/thundernest/appointment - ``` - -2. Install, configure and run python backend (it's recommended to do this in a virtual environment) - - ```bash - cd appointment - pip install -r backend/requirements.txt - touch backend/src/appointment.db # when using sqlite - cp backend/.env.example backend/.env # add your own configuration here - uvicorn --factory backend.src.appointment.main:server --host 0.0.0.0 --port 5000 - ``` - - You can now access the backend at [localhost:5000](http://localhost:5000). - -3. Install and run vue frontend in a second bash - - ```bash - cd frontend - yarn install - yarn serve - ``` - - You can now access the frontend at [localhost:8080](http://localhost:8080). - -4. (optional) Run database migrations - - ```bash - cd backend - cp alembic.ini.example alembic.ini # add your own configuration here - alembic init migrations # init migrations once - alembic current # check database state - alembic upgrade head # migrate to latest state - alembic revision -m "create ... table" # create a new migration - ``` - -## Testing - -To run tests, first install Pytest - -```bash -pip install pytest httpx -``` - -Create an Auth0 test user and add the credentials of that user to `AUTH0_TEST_USER` and `AUTH0_TEST_PASS` in your `.env`. Then `cd` into the project root und simply run - -```bash -pytest -``` - -Note: Since tests include endpoints that trigger mail sending, there must be a running smtp server on your testing system. You can simply run the Python built in server (according to your environment configuration): - -```bash -python -m smtpd -n -c DebuggingServer localhost:25 -``` - -## Contributing - -Contributions are very welcome. Please lint/format code before creating PRs. - -### Backend - -Backend is formatted using Ruff and Black. - -```bash -pip install ruff -pip install black -``` - -Commands (from git root) - -```bash -ruff backend -black backend -``` - -### Frontend - -Frontend is formatted using ESlint with airbnb rules. - -Commands (from /frontend) - -```bash -yarn run lint -yarn run lint --fix -``` +# Thunderbird Appointment + +Invite others to grab times on your calendar. Choose a date. Make appointments as easy as it gets. + +## Get started + +You can either build preconfigured docker containers (database, backend and frontend) or manually set up the application. A more detailed documentation can befound in the [docs folder](./docs/README.md). + +### With Docker + +```bash +git clone https://github.com/thunderbird/appointment +cd appointment +docker-compose up -d --build +``` + +A MySQL database will be accessible via `localhost:3306` with username and password set to: `tba` + +To init database or run migrations, the backend offers a cimple CLI interface: + +```bash +run-command update-db +``` + +### Manual Setup + +Make sure to have the following prerequisites available: + +```plain +Python >= 3.10 +Node.js >= 16.0 +``` + +Run application for development with hot reloading backend and frontend: + +1. Get the application data + + ```bash + git clone https://github.com/thunderbird/appointment + ``` + +2. Install, configure and run python backend (it's recommended to do this in a virtual environment) + + ```bash + cd appointment + pip install -r backend/requirements.txt + touch backend/src/appointment.db # when using sqlite + cp backend/.env.example backend/.env # add your own configuration here + uvicorn --factory backend.src.appointment.main:server --host 0.0.0.0 --port 5000 + ``` + + You can now access the backend at [localhost:5000](http://localhost:5000). + +3. Install and run vue frontend in a second bash + + ```bash + cd frontend + yarn install + yarn serve + ``` + + You can now access the frontend at [localhost:8080](http://localhost:8080). + +4. (optional) Run database migrations + + ```bash + cd backend + cp alembic.ini.example alembic.ini # add your own configuration here + alembic init migrations # init migrations once + alembic current # check database state + alembic upgrade head # migrate to latest state + alembic revision -m "create ... table" # create a new migration + ``` + +## Testing + +To run tests, first install Pytest + +```bash +pip install pytest httpx +``` + +Create an Auth0 test user and add the credentials of that user to `AUTH0_TEST_USER` and `AUTH0_TEST_PASS` in your `.env`. Then `cd` into the project root und simply run + +```bash +pytest +``` + +Note: Since tests include endpoints that trigger mail sending, there must be a running smtp server on your testing system. You can simply run the Python built in server (according to your environment configuration): + +```bash +python -m smtpd -n -c DebuggingServer localhost:25 +``` + +## Contributing + +Contributions are very welcome. Please lint/format code before creating PRs. + +### Backend + +Backend is formatted using Ruff and Black. + +```bash +pip install ruff +pip install black +``` + +Commands (from git root) + +```bash +ruff backend +black backend +``` + +### Frontend + +Frontend is formatted using ESlint with airbnb rules. + +Commands (from /frontend) + +```bash +yarn run lint +yarn run lint --fix +``` diff --git a/backend/.env.example b/backend/.env.example index 8439fc47..2e009756 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -1,74 +1,74 @@ -# Appointment backend configuration. - -# -- GENERAL -- -# Logging level: DEBUG|INFO|WARNING|ERROR|CRITICAL -LOG_LEVEL=ERROR -LOG_USE_STREAM=1 - -# -- FRONTEND -- -FRONTEND_URL=http://localhost:8080 -# Leave blank for no short url -SHORT_BASE_URL= - -# -- DATABASE -- -DATABASE_URL= -DATABASE_SECRETS= -# Secret phrase for database encryption (e.g. create it by running `openssl rand -hex 32`) -DB_SECRET= - -# -- AUTH0 -- -# Management API -AUTH0_API_CLIENT_ID= -AUTH0_API_SECRET= -# Auth API -AUTH0_API_DOMAIN= -AUTH0_API_AUDIENCE= -# Role keys, configurable in Auth0 User Management -> Roles -AUTH0_API_ROLE_ADMIN= -AUTH0_API_ROLE_BASIC= -AUTH0_API_ROLE_PLUS= -AUTH0_API_ROLE_PRO= - -# -- MAIL -- -# Connection security: SSL|STARTTLS|NONE -SMTP_SECURITY=SSL -# Address and port of the SMTP server -SMTP_URL= -SMTP_PORT= -# SMTP user credentials -SMTP_USER= -SMTP_PASS= -# Authorized email address for sending emails, leave empty to default to organizer -SMTP_SENDER= - -# -- TIERS -- -# Max number of calendars to be simultanously connected for members of the basic tier -TIER_BASIC_CALENDAR_LIMIT=3 -# Max number of calendars to be simultanously connected for members of the plus tier -TIER_PLUS_CALENDAR_LIMIT=5 -# Max number of calendars to be simultanously connected for members of the pro tier -TIER_PRO_CALENDAR_LIMIT=10 - -# -- GOOGLE AUTH -- -GOOGLE_AUTH_CLIENT_ID= -GOOGLE_AUTH_SECRET= -GOOGLE_AUTH_PROJECT_ID= -GOOGLE_AUTH_CALLBACK=http://localhost:5000/google/callback - -# -- SIGNED URL SECRET -- -# Shared secret for url signing (e.g. create it by running `openssl rand -hex 32`) -SIGNED_SECRET= -# If empty, sentry will be disabled -SENTRY_DSN= -# Possible values: prod, dev -APP_ENV=dev - -# -- TESTING -- -AUTH0_TEST_USER= -AUTH0_TEST_PASS= -CALDAV_TEST_PRINCIPAL_URL= -CALDAV_TEST_CALENDAR_URL= -CALDAV_TEST_USER= -CALDAV_TEST_PASS= -GOOGLE_TEST_USER= -GOOGLE_TEST_PASS= +# Appointment backend configuration. + +# -- GENERAL -- +# Logging level: DEBUG|INFO|WARNING|ERROR|CRITICAL +LOG_LEVEL=ERROR +LOG_USE_STREAM=1 + +# -- FRONTEND -- +FRONTEND_URL=http://localhost:8080 +# Leave blank for no short url +SHORT_BASE_URL= + +# -- DATABASE -- +DATABASE_URL= +DATABASE_SECRETS= +# Secret phrase for database encryption (e.g. create it by running `openssl rand -hex 32`) +DB_SECRET= + +# -- AUTH0 -- +# Management API +AUTH0_API_CLIENT_ID= +AUTH0_API_SECRET= +# Auth API +AUTH0_API_DOMAIN= +AUTH0_API_AUDIENCE= +# Role keys, configurable in Auth0 User Management -> Roles +AUTH0_API_ROLE_ADMIN= +AUTH0_API_ROLE_BASIC= +AUTH0_API_ROLE_PLUS= +AUTH0_API_ROLE_PRO= + +# -- MAIL -- +# Connection security: SSL|STARTTLS|NONE +SMTP_SECURITY=SSL +# Address and port of the SMTP server +SMTP_URL= +SMTP_PORT= +# SMTP user credentials +SMTP_USER= +SMTP_PASS= +# Authorized email address for sending emails +SMTP_SENDER= + +# -- TIERS -- +# Max number of calendars to be simultanously connected for members of the basic tier +TIER_BASIC_CALENDAR_LIMIT=3 +# Max number of calendars to be simultanously connected for members of the plus tier +TIER_PLUS_CALENDAR_LIMIT=5 +# Max number of calendars to be simultanously connected for members of the pro tier +TIER_PRO_CALENDAR_LIMIT=10 + +# -- GOOGLE AUTH -- +GOOGLE_AUTH_CLIENT_ID= +GOOGLE_AUTH_SECRET= +GOOGLE_AUTH_PROJECT_ID= +GOOGLE_AUTH_CALLBACK=http://localhost:5000/google/callback + +# -- SIGNED URL SECRET -- +# Shared secret for url signing (e.g. create it by running `openssl rand -hex 32`) +SIGNED_SECRET= +# If empty, sentry will be disabled +SENTRY_DSN= +# Possible values: prod, dev +APP_ENV=dev + +# -- TESTING -- +AUTH0_TEST_USER= +AUTH0_TEST_PASS= +CALDAV_TEST_PRINCIPAL_URL= +CALDAV_TEST_CALENDAR_URL= +CALDAV_TEST_USER= +CALDAV_TEST_PASS= +GOOGLE_TEST_USER= +GOOGLE_TEST_PASS= diff --git a/backend/Dockerfile b/backend/Dockerfile index 473c5c7d..dfe1219f 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,23 +1,23 @@ -FROM python:3.11-buster - -RUN mkdir app -WORKDIR /app - -ENV PATH="${PATH}:/root/.local/bin" -ENV PYTHONPATH=. - -RUN mkdir scripts - -COPY requirements.txt . -COPY pyproject.toml . -COPY alembic.ini.example alembic.ini -COPY scripts/dev-entry.sh scripts/dev-entry.sh - -# Dev only -COPY .env . - -RUN pip install --upgrade pip -RUN pip install . - -EXPOSE 5000 +FROM python:3.11-buster + +RUN mkdir app +WORKDIR /app + +ENV PATH="${PATH}:/root/.local/bin" +ENV PYTHONPATH=. + +RUN mkdir scripts + +COPY requirements.txt . +COPY pyproject.toml . +COPY alembic.ini.example alembic.ini +COPY scripts/dev-entry.sh scripts/dev-entry.sh + +# Dev only +COPY .env . + +RUN pip install --upgrade pip +RUN pip install . + +EXPOSE 5000 CMD ["/bin/sh", "./scripts/dev-entry.sh"] \ No newline at end of file diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 72d7982e..46fc70b7 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -10,7 +10,7 @@ run-command = "src.appointment.main:cli" [project.urls] homepage = "https://appointment.day" -repository = "https://github.com/thundernest/appointment.git" +repository = "https://github.com/thunderbird/appointment.git" [project.optional-dependencies] cli = [ diff --git a/backend/requirements.txt b/backend/requirements.txt old mode 100755 new mode 100644 index 8c4b4fd5..245af1f1 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,19 +1,19 @@ -alembic==1.9.3 -auth0-python==4.0.0 -caldav==1.0.1 -cryptography==39.0.1 -fastapi-auth0==0.3.2 -fastapi==0.91.0 -google-api-python-client==2.85.0 -google-auth-httplib2==0.1.0 -google-auth-oauthlib==1.0.0 -jinja2==3.1.2 -icalendar==5.0.4 -mysqlclient==2.1.1 -mysql-connector-python==8.0.32 -python-dotenv==1.0.0 -sentry-sdk==1.26.0 -sqlalchemy-utils==0.39.0 -sqlalchemy==1.4.40 -uvicorn==0.20.0 -validators==0.20.0 +alembic==1.9.3 +auth0-python==4.0.0 +caldav==1.0.1 +cryptography==39.0.1 +fastapi-auth0==0.3.2 +fastapi==0.91.0 +google-api-python-client==2.85.0 +google-auth-httplib2==0.1.0 +google-auth-oauthlib==1.0.0 +jinja2==3.1.2 +icalendar==5.0.4 +mysqlclient==2.1.1 +mysql-connector-python==8.0.32 +python-dotenv==1.0.0 +sentry-sdk==1.26.0 +sqlalchemy-utils==0.39.0 +sqlalchemy==1.4.40 +uvicorn==0.20.0 +validators==0.20.0 diff --git a/backend/scripts/dev-entry.sh b/backend/scripts/dev-entry.sh old mode 100755 new mode 100644 diff --git a/backend/scripts/entry.sh b/backend/scripts/entry.sh old mode 100755 new mode 100644 diff --git a/backend/src/appointment/controller/auth.py b/backend/src/appointment/controller/auth.py index a756d6ce..1de6f2c2 100644 --- a/backend/src/appointment/controller/auth.py +++ b/backend/src/appointment/controller/auth.py @@ -1,103 +1,122 @@ -"""Module: auth - -Handle authentification with Auth0 and get subscription data. -""" -import logging -import os -import hashlib -import hmac -import secrets - -from sqlalchemy.orm import Session -from ..database import repo, schemas, models -from fastapi_auth0 import Auth0, Auth0User -from auth0.authentication import GetToken -from auth0.management import Auth0 as ManageAuth0 -from auth0.exceptions import Auth0Error, RateLimitError, TokenValidationError - - -domain = os.getenv("AUTH0_API_DOMAIN") -api_client_id = os.getenv("AUTH0_API_CLIENT_ID") -api_secret = os.getenv("AUTH0_API_SECRET") -api_audience = os.getenv("AUTH0_API_AUDIENCE") - - -class Auth: - def __init__(self): - """verify Appointment subscription via Auth0, return user or None""" - scopes = {"read:calendars": "Read Calendar Ressources"} # TODO - self.auth0 = Auth0(domain=domain, api_audience=api_audience, scopes=scopes) - - def persist_user(self, db: Session, user: Auth0User): - """Sync authed user to Appointment db""" - if not db: - return None - # get the current user via the authed user - api = self.init_management_api() - if not api: - logging.warning( - "[auth.persist_user] A frontend authed user (ID: %s, name: %s) was not found via management API", - str(user.id), - user.name, - ) - return None - authenticated_subscriber = api.users.get(user.id) - # check if user exists as subsriber - if authenticated_subscriber: - # search for subscriber in Appointment db - db_subscriber = repo.get_subscriber_by_email(db=db, email=authenticated_subscriber["email"]) - # if authenticated subscriber doesn't exist yet, add them - if db_subscriber is None: - subscriber = schemas.SubscriberBase( - username=authenticated_subscriber["email"], # username == email for now - email=authenticated_subscriber["email"], - name=authenticated_subscriber["name"], - level=models.SubscriberLevel.pro, # TODO - ) - db_subscriber = repo.create_subscriber(db=db, subscriber=subscriber) - - # Generate an initial short link hash if they don't have one already - if db_subscriber.short_link_hash is None: - repo.update_subscriber( - db, - schemas.SubscriberAuth( - email=db_subscriber.email, - username=db_subscriber.username, - short_link_hash=secrets.token_hex(32), - ), - db_subscriber.id, - ) - - return db_subscriber - return None - - def init_management_api(self): - """Helper function to get a management api token""" - try: - get_token = GetToken(domain, api_client_id, client_secret=api_secret) - token = get_token.client_credentials("https://{}/api/v2/".format(domain)) - management = ManageAuth0(domain, token["access_token"]) - except RateLimitError as error: - logging.error("[auth.init_management_api] A rate limit error occurred: " + str(error)) - return None - except Auth0Error as error: - logging.error("[auth.init_management_api] An Auth0 error occurred: " + str(error)) - return None - except TokenValidationError as error: - logging.error("[auth.init_management_api] A token validation error occurred" + str(error)) - return None - - return management - - -def sign_url(url: str): - """helper to sign a url for given user data""" - secret = os.getenv("SIGNED_SECRET") - - if not secret: - raise RuntimeError("Missing signed secret environment variable") - - key = bytes(secret, "UTF-8") - message = f"{url}".encode() - signature = hmac.new(key, message, hashlib.sha256).hexdigest() - return signature +"""Module: auth + +Handle authentification with Auth0 and get subscription data. +""" +import logging +import os +import hashlib +import hmac +import secrets + +from sqlalchemy.orm import Session +from ..database import repo, schemas, models +from fastapi_auth0 import Auth0, Auth0User +from auth0.authentication import GetToken +from auth0.management import Auth0 as ManageAuth0 +from auth0.exceptions import Auth0Error, RateLimitError, TokenValidationError + + +domain = os.getenv("AUTH0_API_DOMAIN") +api_client_id = os.getenv("AUTH0_API_CLIENT_ID") +api_secret = os.getenv("AUTH0_API_SECRET") +api_audience = os.getenv("AUTH0_API_AUDIENCE") + + +class Auth: + def __init__(self): + """verify Appointment subscription via Auth0, return user or None""" + scopes = {"read:calendars": "Read Calendar Ressources"} # TODO + self.auth0 = Auth0(domain=domain, api_audience=api_audience, scopes=scopes) + + def persist_user(self, db: Session, user: Auth0User): + """Sync authed user to Appointment db""" + if not db: + return None + # get the current user via the authed user + api = self.init_management_api() + if not api: + logging.warning( + "[auth.persist_user] A frontend authed user (ID: %s, name: %s) was not found via management API", + str(user.id), + user.name, + ) + return None + authenticated_subscriber = api.users.get(user.id) + # check if user exists as subsriber + if authenticated_subscriber: + # search for subscriber in Appointment db + db_subscriber = repo.get_subscriber_by_email(db=db, email=authenticated_subscriber["email"]) + # if authenticated subscriber doesn't exist yet, add them + if db_subscriber is None: + subscriber = schemas.SubscriberBase( + username=authenticated_subscriber["email"], # username == email for now + email=authenticated_subscriber["email"], + name=authenticated_subscriber["name"], + level=models.SubscriberLevel.pro, # TODO + ) + db_subscriber = repo.create_subscriber(db=db, subscriber=subscriber) + + # Generate an initial short link hash if they don't have one already + if db_subscriber.short_link_hash is None: + repo.update_subscriber( + db, + schemas.SubscriberAuth( + email=db_subscriber.email, + username=db_subscriber.username, + short_link_hash=secrets.token_hex(32), + ), + db_subscriber.id, + ) + + return db_subscriber + return None + + def init_management_api(self): + """Helper function to get a management api token""" + try: + get_token = GetToken(domain, api_client_id, client_secret=api_secret) + token = get_token.client_credentials("https://{}/api/v2/".format(domain)) + management = ManageAuth0(domain, token["access_token"]) + except RateLimitError as error: + logging.error("[auth.init_management_api] A rate limit error occurred: " + str(error)) + return None + except Auth0Error as error: + logging.error("[auth.init_management_api] An Auth0 error occurred: " + str(error)) + return None + except TokenValidationError as error: + logging.error("[auth.init_management_api] A token validation error occurred" + str(error)) + return None + + return management + + +def sign_url(url: str): + """helper to sign a given url""" + secret = os.getenv("SIGNED_SECRET") + + if not secret: + raise RuntimeError("Missing signed secret environment variable") + + key = bytes(secret, "UTF-8") + message = f"{url}".encode() + signature = hmac.new(key, message, hashlib.sha256).hexdigest() + return signature + + +def signed_url_by_subscriber(subscriber: schemas.Subscriber): + """helper to generated signed url for given subscriber""" + short_url = os.getenv("SHORT_BASE_URL") + base_url = f"{os.getenv('FRONTEND_URL')}/user" + + # If we don't have a short url, then use the default url with /user added to it + if not short_url: + short_url = base_url + + # We sign with a different hash that the end-user doesn't have access to + # We also need to use the default url, as short urls are currently setup as a redirect + url = f"{base_url}/{subscriber.username}/{subscriber.short_link_hash}" + + signature = sign_url(url) + + # We return with the signed url signature + return f"{short_url}/{subscriber.username}/{signature}" diff --git a/backend/src/appointment/controller/calendar.py b/backend/src/appointment/controller/calendar.py index 41114a61..d3c2d368 100644 --- a/backend/src/appointment/controller/calendar.py +++ b/backend/src/appointment/controller/calendar.py @@ -16,6 +16,9 @@ from ..controller.mailer import Attachment, InvitationMail +DATEFMT = "%Y-%m-%d" + + class GoogleConnector: """Generic interface for Google Calendar REST API. This should match CaldavConnector (except for the constructor). @@ -63,8 +66,8 @@ def list_calendars(self): def list_events(self, start, end): """find all events in given date range on the remote server""" - time_min = datetime.strptime(start, "%Y-%m-%d").isoformat() + "Z" - time_max = datetime.strptime(end, "%Y-%m-%d").isoformat() + "Z" + time_min = datetime.strptime(start, DATEFMT).isoformat() + "Z" + time_max = datetime.strptime(end, DATEFMT).isoformat() + "Z" # We're storing google cal id in user...for now. remote_events = self.google_client.list_events(self.calendar_id, time_min, time_max, self.google_token) @@ -136,8 +139,9 @@ def create_event( return event def delete_events(self, start): - """delete all events in given date range from the server""" - # Not used? + """delete all events in given date range from the server + Not intended to be used in production. For cleaning purposes after testing only. + """ pass @@ -173,8 +177,8 @@ def list_events(self, start, end): events = [] calendar = self.client.calendar(url=self.url) result = calendar.search( - start=datetime.strptime(start, "%Y-%m-%d"), - end=datetime.strptime(end, "%Y-%m-%d"), + start=datetime.strptime(start, DATEFMT), + end=datetime.strptime(end, DATEFMT), event=True, expand=True, ) @@ -223,7 +227,9 @@ def create_event( return event def delete_events(self, start): - """delete all events in given date range from the server""" + """delete all events in given date range from the server + Not intended to be used in production. For cleaning purposes after testing only. + """ calendar = self.client.calendar(url=self.url) result = calendar.events() count = 0 @@ -277,8 +283,8 @@ def send_vevent( mail = InvitationMail(sender=organizer.email, to=attendee.email, attachments=[invite]) mail.send() - def available_slots_from_schedule(s: schemas.ScheduleBase): - """This helper calculates a list of slots according to the given schedule.""" + def available_slots_from_schedule(s: schemas.ScheduleBase) -> list[schemas.SlotBase]: + """This helper calculates a list of slots according to the given schedule configuration.""" now = datetime.utcnow() earliest_start = now + timedelta(minutes=s.earliest_booking) farthest_end = now + timedelta(minutes=s.farthest_booking) @@ -312,9 +318,9 @@ def available_slots_from_schedule(s: schemas.ScheduleBase): pointer = next_date return slots - def events_set_difference(a_list: list[schemas.SlotBase], b_list: list[schemas.Event]): + def events_set_difference(a_list: list[schemas.SlotBase], b_list: list[schemas.Event]) -> list[schemas.SlotBase]: """This helper removes all events from list A, which have a time collision with any event in list B - and returns all remaining elements from A as new list. + and returns all remaining elements from A as new list. """ available_slots = [] for a in a_list: @@ -332,3 +338,38 @@ def events_set_difference(a_list: list[schemas.SlotBase], b_list: list[schemas.E if not collision_found: available_slots.append(a) return available_slots + + def existing_events_for_schedule( + schedule: schemas.Schedule, + calendars: list[schemas.Calendar], + subscriber: schemas.Subscriber, + google_client: GoogleClient, + db + ) -> list[schemas.Event]: + """This helper retrieves all events existing in given calendars for the scheduled date range + """ + existingEvents = [] + # handle calendar events + for calendar in calendars: + if calendar.provider == CalendarProvider.google: + con = GoogleConnector( + db=db, + google_client=google_client, + calendar_id=calendar.user, + subscriber_id=subscriber.id, + google_tkn=subscriber.google_tkn, + ) + else: + con = CalDavConnector(calendar.url, calendar.user, calendar.password) + farthest_end = datetime.utcnow() + timedelta(minutes=schedule.farthest_booking) + start = schedule.start_date.strftime(DATEFMT) + end = schedule.end_date.strftime(DATEFMT) if schedule.end_date else farthest_end.strftime(DATEFMT) + existingEvents.extend(con.list_events(start, end)) + # handle already requested time slots + for slot in schedule.slots: + existingEvents.append(schemas.Event( + title=schedule.name, + start=slot.start.isoformat(), + end=(slot.start + timedelta(minutes=slot.duration)).isoformat(), + )) + return existingEvents diff --git a/backend/src/appointment/controller/google_client.py b/backend/src/appointment/controller/google_client.py index 8cf1a584..e9650785 100644 --- a/backend/src/appointment/controller/google_client.py +++ b/backend/src/appointment/controller/google_client.py @@ -144,7 +144,10 @@ def sync_calendars(self, db, subscriber_id: int, token): # add calendar try: repo.update_or_create_subscriber_calendar( - db=db, calendar=cal, calendar_url=calendar.get("id"), subscriber_id=subscriber_id + db=db, + calendar=cal, + calendar_url=calendar.get("id"), + subscriber_id=subscriber_id ) except Exception as err: logging.warning( diff --git a/backend/src/appointment/controller/mailer.py b/backend/src/appointment/controller/mailer.py index c180655f..3d023cc7 100644 --- a/backend/src/appointment/controller/mailer.py +++ b/backend/src/appointment/controller/mailer.py @@ -10,6 +10,7 @@ import jinja2 import validators +from datetime import datetime from html import escape from email import encoders from email.mime.base import MIMEBase @@ -36,14 +37,14 @@ def __init__(self, mime: tuple[str], filename: str, data: str): class Mailer: def __init__( self, - sender: str, to: str, + sender: str = os.getenv("SMTP_SENDER"), subject: str = "", html: str = "", plain: str = "", - attachments: list = [Attachment], + attachments: list[Attachment] = [], ): - self.sender = os.getenv("SMTP_SENDER") or sender + self.sender = sender self.to = to self.subject = subject self.body_html = html @@ -51,7 +52,7 @@ def __init__( self.attachments = attachments def html(self): - """provide email body as html""" + """provide email body as html per default""" return self.body_html def text(self): @@ -130,9 +131,66 @@ def __init__(self, *args, **kwargs): """init Mailer with invitation specific defaults""" defaultKwargs = { "subject": "[TBA] Invitation sent from Thunderbird Appointment", - "plain": "This message is sent from Appointment.", + "plain": "This message is sent from Thunderbird Appointment.", } super(InvitationMail, self).__init__(*args, **defaultKwargs, **kwargs) def html(self): return get_template("invite.jinja2").render() + + +class ConfirmationMail(Mailer): + def __init__(self, confirmUrl, denyUrl, attendee, date, *args, **kwargs): + """init Mailer with confirmation specific defaults""" + self.attendee = attendee + self.date = date + self.confirmUrl = confirmUrl + self.denyUrl = denyUrl + defaultKwargs = { + "subject": "[TBA] Confirm booking request from Thunderbird Appointment", + "plain": """ +{name} ({email}) just requested this time slot from your schedule: {date} + +Visit this link to confirm the booking request: +{confirm} + +Or this link if you want to deny it: +{deny} + +This message is sent from Thunderbird Appointment. + """.format( + name=self.attendee.name, + email=self.attendee.email, + date=self.date, + confirm=self.confirmUrl, + deny=self.denyUrl + ), + } + super(ConfirmationMail, self).__init__(*args, **defaultKwargs, **kwargs) + + def html(self): + return get_template("confirm.jinja2").render( + attendee=self.attendee, + date=self.date, + confirm=self.confirmUrl, + deny=self.denyUrl, + ) + + +class RejectionMail(Mailer): + def __init__(self, owner, date, *args, **kwargs): + """init Mailer with rejection specific defaults""" + self.owner = owner + self.date = date + defaultKwargs = { + "subject": "[TBA] Booking request declined", + "plain": """ +{name} denied your booking request for this time slot: {date}. + +This message is sent from Thunderbird Appointment. + """.format(name=self.owner.name, date=self.date), + } + super(RejectionMail, self).__init__(*args, **defaultKwargs, **kwargs) + + def html(self): + return get_template("rejected.jinja2").render(owner=self.owner, date=self.date) diff --git a/backend/src/appointment/database/models.py b/backend/src/appointment/database/models.py index 182ffb51..51807344 100644 --- a/backend/src/appointment/database/models.py +++ b/backend/src/appointment/database/models.py @@ -1,208 +1,202 @@ -"""Module: models - -Definitions of database tables and their relationships. -""" -import enum -import os -import uuid -from sqlalchemy import Column, ForeignKey, Integer, String, DateTime, Enum, Boolean, JSON, Date, Time -from sqlalchemy_utils import StringEncryptedType -from sqlalchemy_utils.types.encrypted.encrypted_type import AesEngine -from sqlalchemy.orm import relationship -from sqlalchemy.sql import func -from .database import Base - - -def secret(): - return os.getenv("DB_SECRET") - - -def random_slug(): - return "".join(str(uuid.uuid4()).split("-")) - - -class SubscriberLevel(enum.Enum): - basic = 1 # basic tier - plus = 2 # advanced tier - pro = 3 # pro tier - admin = 99 # unlimited tier - - -class AppointmentStatus(enum.Enum): - draft = 1 # appointment was created but not published yet - opened = 2 # appointment is published and waiting for attendees - closed = 3 # appointment is published and fulfilled or manually closed for attendees - - -class LocationType(enum.Enum): - inperson = 1 # appointment is held in person - online = 2 # appointment is held online - - -class CalendarProvider(enum.Enum): - caldav = 1 # calendar provider serves via CalDAV - google = 2 # calendar provider is Google via its own Rest API - - -# Use ISO 8601 format to specify day of week -class DayOfWeek(enum.Enum): - Monday = 1 - Tuesday = 2 - Wednesday = 3 - Thursday = 4 - Friday = 5 - Saturday = 6 - Sunday = 7 - - -class Subscriber(Base): - __tablename__ = "subscribers" - - id = Column(Integer, primary_key=True, index=True) - username = Column( - StringEncryptedType(String, secret, AesEngine, "pkcs5", length=255), - unique=True, - index=True, - ) - email = Column( - StringEncryptedType(String, secret, AesEngine, "pkcs5", length=255), - unique=True, - index=True, - ) - name = Column(StringEncryptedType(String, secret, AesEngine, "pkcs5", length=255), index=True) - level = Column(Enum(SubscriberLevel), default=SubscriberLevel.basic, index=True) - timezone = Column(StringEncryptedType(String, secret, AesEngine, "pkcs5", length=255), index=True) - google_tkn = Column( - StringEncryptedType(String, secret, AesEngine, "pkcs5", length=2048), - index=False, - ) - # Temp storage for verifying google state tokens between authentication - google_state = Column(StringEncryptedType(String, secret, AesEngine, "pkcs5", length=512), index=False) - google_state_expires_at = Column(DateTime) - short_link_hash = Column(StringEncryptedType(String, secret, AesEngine, "pkcs5", length=255), index=False) - - calendars = relationship("Calendar", cascade="all,delete", back_populates="owner") - slots = relationship("Slot", cascade="all,delete", back_populates="subscriber") - - -class Calendar(Base): - __tablename__ = "calendars" - - id = Column(Integer, primary_key=True, index=True) - owner_id = Column(Integer, ForeignKey("subscribers.id")) - provider = Column(Enum(CalendarProvider), default=CalendarProvider.caldav) - title = Column(StringEncryptedType(String, secret, AesEngine, "pkcs5", length=255), index=True) - color = Column(StringEncryptedType(String, secret, AesEngine, "pkcs5", length=32), index=True) - url = Column( - StringEncryptedType(String, secret, AesEngine, "pkcs5", length=2048), - index=False, - ) - user = Column(StringEncryptedType(String, secret, AesEngine, "pkcs5", length=255), index=True) - password = Column(StringEncryptedType(String, secret, AesEngine, "pkcs5", length=255)) - connected = Column(Boolean, index=True, default=False) - connected_at = Column(DateTime) - - owner = relationship("Subscriber", back_populates="calendars") - appointments = relationship("Appointment", cascade="all,delete", back_populates="calendar") - schedules = relationship("Schedule", cascade="all,delete", back_populates="calendar") - - -class Appointment(Base): - __tablename__ = "appointments" - - id = Column(Integer, primary_key=True, index=True) - calendar_id = Column(Integer, ForeignKey("calendars.id")) - time_created = Column(DateTime, server_default=func.now()) - time_updated = Column(DateTime, server_default=func.now(), onupdate=func.now()) - duration = Column(Integer) - title = Column(StringEncryptedType(String, secret, AesEngine, "pkcs5", length=255)) - location_type = Column(Enum(LocationType), default=LocationType.inperson) - location_suggestions = Column(String(255)) - location_selected = Column(Integer) - location_name = Column(StringEncryptedType(String, secret, AesEngine, "pkcs5", length=255)) - location_url = Column(StringEncryptedType(String, secret, AesEngine, "pkcs5", length=2048)) - location_phone = Column(StringEncryptedType(String, secret, AesEngine, "pkcs5", length=255)) - details = Column(StringEncryptedType(String, secret, AesEngine, "pkcs5", length=255)) - slug = Column( - StringEncryptedType(String, secret, AesEngine, "pkcs5", length=255), - unique=True, - index=True, - ) - keep_open = Column(Boolean) - status = Column(Enum(AppointmentStatus), default=AppointmentStatus.draft) - - calendar = relationship("Calendar", back_populates="appointments") - slots = relationship("Slot", cascade="all,delete", back_populates="appointment") - - -class Attendee(Base): - __tablename__ = "attendees" - - id = Column(Integer, primary_key=True, index=True) - email = Column(StringEncryptedType(String, secret, AesEngine, "pkcs5", length=255), index=True) - name = Column(StringEncryptedType(String, secret, AesEngine, "pkcs5", length=255), index=True) - - slots = relationship("Slot", cascade="all,delete", back_populates="attendee") - - -class Slot(Base): - __tablename__ = "slots" - - id = Column(Integer, primary_key=True, index=True) - appointment_id = Column(Integer, ForeignKey("appointments.id")) - attendee_id = Column(Integer, ForeignKey("attendees.id")) - subscriber_id = Column(Integer, ForeignKey("subscribers.id")) - time_updated = Column(DateTime, server_default=func.now(), onupdate=func.now()) - start = Column(DateTime) - duration = Column(Integer) - - appointment = relationship("Appointment", back_populates="slots") - attendee = relationship("Attendee", cascade="all,delete", back_populates="slots") - subscriber = relationship("Subscriber", back_populates="slots") - - -class Schedule(Base): - __tablename__ = "schedules" - - id = Column(Integer, primary_key=True, index=True) - calendar_id = Column(Integer, ForeignKey("calendars.id")) - active = Column(Boolean, index=True, default=True) - name = Column(StringEncryptedType(String, secret, AesEngine, "pkcs5", length=255), index=True) - location_type = Column(Enum(LocationType), default=LocationType.inperson) - location_url = Column(StringEncryptedType(String, secret, AesEngine, "pkcs5", length=2048)) - details = Column(StringEncryptedType(String, secret, AesEngine, "pkcs5", length=255)) - start_date = Column(StringEncryptedType(Date, secret, AesEngine, "pkcs5", length=255), index=True) - end_date = Column(StringEncryptedType(Date, secret, AesEngine, "pkcs5", length=255), index=True) - start_time = Column(StringEncryptedType(Time, secret, AesEngine, "pkcs5", length=255), index=True) - end_time = Column(StringEncryptedType(Time, secret, AesEngine, "pkcs5", length=255), index=True) - earliest_booking = Column(Integer, default=1440) # in minutes, defaults to 24 hours - farthest_booking = Column(Integer, default=20160) # in minutes, defaults to 2 weeks - weekdays = Column(JSON, default="[1,2,3,4,5]") # list of ISO weekdays, Mo-Su => 1-7 - slot_duration = Column(Integer, default=30) # defaults to 30 minutes - time_created = Column(DateTime, server_default=func.now()) - time_updated = Column(DateTime, server_default=func.now(), onupdate=func.now()) - - calendar = relationship("Calendar", back_populates="schedules") - availabilities = relationship("Availability", cascade="all,delete", back_populates="schedule") - - -class Availability(Base): - """This table will be used as soon as the application provides custom availability - in addition to the general availability - """ - - __tablename__ = "availabilities" - - id = Column(Integer, primary_key=True, index=True) - schedule_id = Column(Integer, ForeignKey("schedules.id")) - day_of_week = Column(StringEncryptedType(String, secret, AesEngine, "pkcs5", length=255), index=True) - start_time = Column(StringEncryptedType(String, secret, AesEngine, "pkcs5", length=255), index=True) - end_time = Column(StringEncryptedType(String, secret, AesEngine, "pkcs5", length=255), index=True) - min_time_before_meeting = Column( - StringEncryptedType(String, secret, AesEngine, "pkcs5", length=255), index=True - ) # i.e., Can't book if it's less than X minutes before start time. - slot_duration = Column(Integer) # Size of the Slot that can be booked. - time_created = Column(DateTime, server_default=func.now()) - time_updated = Column(DateTime, server_default=func.now(), onupdate=func.now()) - - schedule = relationship("Schedule", back_populates="availabilities") +"""Module: models + +Definitions of database tables and their relationships. +""" +import enum +import os +import uuid +from sqlalchemy import Column, ForeignKey, Integer, String, DateTime, Enum, Boolean, JSON, Date, Time +from sqlalchemy_utils import StringEncryptedType +from sqlalchemy_utils.types.encrypted.encrypted_type import AesEngine +from sqlalchemy.orm import relationship +from sqlalchemy.sql import func +from .database import Base + + +def secret(): + return os.getenv("DB_SECRET") + + +def random_slug(): + return "".join(str(uuid.uuid4()).split("-")) + + +class SubscriberLevel(enum.Enum): + basic = 1 # basic tier + plus = 2 # advanced tier + pro = 3 # pro tier + admin = 99 # unlimited tier + + +class AppointmentStatus(enum.Enum): + draft = 1 # appointment was created but not published yet + opened = 2 # appointment is published and waiting for attendees + closed = 3 # appointment is published and fulfilled or manually closed for attendees + + +class BookingStatus(enum.Enum): + none = 1 # slot status doesn't matter, because the parent object holds the state + requested = 2 # booking slot was requested + booked = 3 # booking slot was assigned + + +class LocationType(enum.Enum): + inperson = 1 # appointment is held in person + online = 2 # appointment is held online + + +class CalendarProvider(enum.Enum): + caldav = 1 # calendar provider serves via CalDAV + google = 2 # calendar provider is Google via its own Rest API + + +# Use ISO 8601 format to specify day of week +class DayOfWeek(enum.Enum): + Monday = 1 + Tuesday = 2 + Wednesday = 3 + Thursday = 4 + Friday = 5 + Saturday = 6 + Sunday = 7 + + +class Subscriber(Base): + __tablename__ = "subscribers" + + id = Column(Integer, primary_key=True, index=True) + username = Column(StringEncryptedType(String, secret, AesEngine, "pkcs5", length=255), unique=True, index=True) + email = Column(StringEncryptedType(String, secret, AesEngine, "pkcs5", length=255), unique=True, index=True) + name = Column(StringEncryptedType(String, secret, AesEngine, "pkcs5", length=255), index=True) + level = Column(Enum(SubscriberLevel), default=SubscriberLevel.basic, index=True) + timezone = Column(StringEncryptedType(String, secret, AesEngine, "pkcs5", length=255), index=True) + google_tkn = Column(StringEncryptedType(String, secret, AesEngine, "pkcs5", length=2048), index=False) + # Temp storage for verifying google state tokens between authentication + google_state = Column(StringEncryptedType(String, secret, AesEngine, "pkcs5", length=512), index=False) + google_state_expires_at = Column(DateTime) + short_link_hash = Column(StringEncryptedType(String, secret, AesEngine, "pkcs5", length=255), index=False) + + calendars = relationship("Calendar", cascade="all,delete", back_populates="owner") + slots = relationship("Slot", cascade="all,delete", back_populates="subscriber") + + +class Calendar(Base): + __tablename__ = "calendars" + + id = Column(Integer, primary_key=True, index=True) + owner_id = Column(Integer, ForeignKey("subscribers.id")) + provider = Column(Enum(CalendarProvider), default=CalendarProvider.caldav) + title = Column(StringEncryptedType(String, secret, AesEngine, "pkcs5", length=255), index=True) + color = Column(StringEncryptedType(String, secret, AesEngine, "pkcs5", length=32), index=True) + url = Column(StringEncryptedType(String, secret, AesEngine, "pkcs5", length=2048), index=False) + user = Column(StringEncryptedType(String, secret, AesEngine, "pkcs5", length=255), index=True) + password = Column(StringEncryptedType(String, secret, AesEngine, "pkcs5", length=255)) + connected = Column(Boolean, index=True, default=False) + connected_at = Column(DateTime) + + owner = relationship("Subscriber", back_populates="calendars") + appointments = relationship("Appointment", cascade="all,delete", back_populates="calendar") + schedules = relationship("Schedule", cascade="all,delete", back_populates="calendar") + + +class Appointment(Base): + __tablename__ = "appointments" + + id = Column(Integer, primary_key=True, index=True) + calendar_id = Column(Integer, ForeignKey("calendars.id")) + time_created = Column(DateTime, server_default=func.now()) + time_updated = Column(DateTime, server_default=func.now(), onupdate=func.now()) + duration = Column(Integer) + title = Column(StringEncryptedType(String, secret, AesEngine, "pkcs5", length=255)) + location_type = Column(Enum(LocationType), default=LocationType.inperson) + location_suggestions = Column(String(255)) + location_selected = Column(Integer) + location_name = Column(StringEncryptedType(String, secret, AesEngine, "pkcs5", length=255)) + location_url = Column(StringEncryptedType(String, secret, AesEngine, "pkcs5", length=2048)) + location_phone = Column(StringEncryptedType(String, secret, AesEngine, "pkcs5", length=255)) + details = Column(StringEncryptedType(String, secret, AesEngine, "pkcs5", length=255)) + slug = Column(StringEncryptedType(String, secret, AesEngine, "pkcs5", length=255), unique=True, index=True) + keep_open = Column(Boolean) + status = Column(Enum(AppointmentStatus), default=AppointmentStatus.draft) + + calendar = relationship("Calendar", back_populates="appointments") + slots = relationship("Slot", cascade="all,delete", back_populates="appointment") + + +class Attendee(Base): + __tablename__ = "attendees" + + id = Column(Integer, primary_key=True, index=True) + email = Column(StringEncryptedType(String, secret, AesEngine, "pkcs5", length=255), index=True) + name = Column(StringEncryptedType(String, secret, AesEngine, "pkcs5", length=255), index=True) + + slots = relationship("Slot", cascade="all,delete", back_populates="attendee") + + +class Slot(Base): + __tablename__ = "slots" + + id = Column(Integer, primary_key=True, index=True) + appointment_id = Column(Integer, ForeignKey("appointments.id")) + schedule_id = Column(Integer, ForeignKey("schedules.id")) + attendee_id = Column(Integer, ForeignKey("attendees.id")) + subscriber_id = Column(Integer, ForeignKey("subscribers.id")) + time_updated = Column(DateTime, server_default=func.now(), onupdate=func.now()) + start = Column(DateTime) + duration = Column(Integer) + # columns for availability bookings + booking_tkn = Column(StringEncryptedType(String, secret, AesEngine, "pkcs5", length=512), index=False) + booking_expires_at = Column(DateTime) + booking_status = Column(Enum(BookingStatus), default=BookingStatus.none) + + appointment = relationship("Appointment", back_populates="slots") + schedule = relationship("Schedule", back_populates="slots") + attendee = relationship("Attendee", cascade="all,delete", back_populates="slots") + subscriber = relationship("Subscriber", back_populates="slots") + + +class Schedule(Base): + __tablename__ = "schedules" + + id = Column(Integer, primary_key=True, index=True) + calendar_id = Column(Integer, ForeignKey("calendars.id")) + active = Column(Boolean, index=True, default=True) + name = Column(StringEncryptedType(String, secret, AesEngine, "pkcs5", length=255), index=True) + location_type = Column(Enum(LocationType), default=LocationType.inperson) + location_url = Column(StringEncryptedType(String, secret, AesEngine, "pkcs5", length=2048)) + details = Column(StringEncryptedType(String, secret, AesEngine, "pkcs5", length=255)) + start_date = Column(StringEncryptedType(Date, secret, AesEngine, "pkcs5", length=255), index=True) + end_date = Column(StringEncryptedType(Date, secret, AesEngine, "pkcs5", length=255), index=True) + start_time = Column(StringEncryptedType(Time, secret, AesEngine, "pkcs5", length=255), index=True) + end_time = Column(StringEncryptedType(Time, secret, AesEngine, "pkcs5", length=255), index=True) + earliest_booking = Column(Integer, default=1440) # in minutes, defaults to 24 hours + farthest_booking = Column(Integer, default=20160) # in minutes, defaults to 2 weeks + weekdays = Column(JSON, default="[1,2,3,4,5]") # list of ISO weekdays, Mo-Su => 1-7 + slot_duration = Column(Integer, default=30) # defaults to 30 minutes + time_created = Column(DateTime, server_default=func.now()) + time_updated = Column(DateTime, server_default=func.now(), onupdate=func.now()) + + calendar = relationship("Calendar", back_populates="schedules") + availabilities = relationship("Availability", cascade="all,delete", back_populates="schedule") + slots = relationship("Slot", cascade="all,delete", back_populates="schedule") + + +class Availability(Base): + """This table will be used as soon as the application provides custom availability + in addition to the general availability + """ + + __tablename__ = "availabilities" + + id = Column(Integer, primary_key=True, index=True) + schedule_id = Column(Integer, ForeignKey("schedules.id")) + day_of_week = Column(StringEncryptedType(String, secret, AesEngine, "pkcs5", length=255), index=True) + start_time = Column(StringEncryptedType(String, secret, AesEngine, "pkcs5", length=255), index=True) + end_time = Column(StringEncryptedType(String, secret, AesEngine, "pkcs5", length=255), index=True) + # Can't book if it's less than X minutes before start time: + min_time_before_meeting = Column(StringEncryptedType(String, secret, AesEngine, "pkcs5", length=255), index=True) + slot_duration = Column(Integer) # Size of the Slot that can be booked. + time_created = Column(DateTime, server_default=func.now()) + time_updated = Column(DateTime, server_default=func.now(), onupdate=func.now()) + + schedule = relationship("Schedule", back_populates="availabilities") diff --git a/backend/src/appointment/database/repo.py b/backend/src/appointment/database/repo.py index 9f5b1b4a..ccdaca25 100644 --- a/backend/src/appointment/database/repo.py +++ b/backend/src/appointment/database/repo.py @@ -1,520 +1,571 @@ -"""Module: repo - -Repository providing CRUD functions for all database models. -""" -import os -import re -import logging -from datetime import timedelta, datetime - -from fastapi import HTTPException -from sqlalchemy.orm import Session -from . import models, schemas -from ..controller.auth import sign_url - - -"""ATTENDEES repository functions -""" - - -def get_attendees_by_subscriber(db: Session, subscriber_id: int): - """For use with the data download. Get attendees by subscriber id.""" - # We need to walk through Calendars to attach Appointments, and Appointments to get Slots - slots = ( - db.query(models.Slot) - .join(models.Appointment) - .join(models.Calendar) - .filter(models.Calendar.owner_id == subscriber_id) - .filter(models.Appointment.calendar_id == models.Calendar.id) - .filter(models.Slot.appointment_id == models.Appointment.id) - .all() - ) - - attendee_ids = list(map(lambda slot: slot.attendee_id if slot.attendee_id is not None else None, slots)) - attendee_ids = filter(lambda attendee: attendee is not None, attendee_ids) - return db.query(models.Attendee).filter(models.Attendee.id.in_(attendee_ids)).all() - - -def delete_attendees_by_subscriber(db: Session, subscriber_id: int): - """Delete all attendees by subscriber""" - attendees = get_attendees_by_subscriber(db, subscriber_id) - - for attendee in attendees: - db.delete(attendee) - db.commit() - - return True - - -""" SUBSCRIBERS repository functions -""" - - -def get_subscriber(db: Session, subscriber_id: int): - """retrieve subscriber by id""" - return db.get(models.Subscriber, subscriber_id) - - -def get_subscriber_by_email(db: Session, email: str): - """retrieve subscriber by email""" - return db.query(models.Subscriber).filter(models.Subscriber.email == email).first() - - -def get_subscriber_by_username(db: Session, username: str): - """retrieve subscriber by username""" - return db.query(models.Subscriber).filter(models.Subscriber.username == username).first() - - -def get_subscriber_by_appointment(db: Session, appointment_id: int): - """retrieve appointment by subscriber username and appointment slug (public)""" - if appointment_id: - return ( - db.query(models.Subscriber) - .join(models.Calendar) - .join(models.Appointment) - .filter(models.Appointment.id == appointment_id) - .first() - ) - return None - - -def get_subscriber_by_google_state(db: Session, state: str): - """retrieve subscriber by google state, you'll have to manually check the google_state_expire_at!""" - if state is None: - return None - return db.query(models.Subscriber).filter(models.Subscriber.google_state == state).first() - - -def create_subscriber(db: Session, subscriber: schemas.SubscriberBase): - """create new subscriber""" - db_subscriber = models.Subscriber(**subscriber.dict()) - db.add(db_subscriber) - db.commit() - db.refresh(db_subscriber) - return db_subscriber - - -def update_subscriber(db: Session, data: schemas.SubscriberIn, subscriber_id: int): - """update all subscriber attributes, they can edit themselves""" - db_subscriber = get_subscriber(db, subscriber_id) - for key, value in data: - if value is not None: - setattr(db_subscriber, key, value) - db.commit() - db.refresh(db_subscriber) - return db_subscriber - - -def delete_subscriber(db: Session, subscriber: models.Subscriber): - """Delete a subscriber by subscriber id""" - db.delete(subscriber) - db.commit() - return True - - -def set_subscriber_google_tkn(db: Session, tkn: str, subscriber_id: int): - """update all subscriber attributes, they can edit themselves""" - db_subscriber = get_subscriber(db, subscriber_id) - db_subscriber.google_tkn = tkn - db.commit() - db.refresh(db_subscriber) - return db_subscriber - - -def set_subscriber_google_state(db: Session, state: str | None, subscriber_id: int): - """temp store the google state so we can refer to it when we get back""" - db_subscriber = get_subscriber(db, subscriber_id) - db_subscriber.google_state = state - - if state is None: - db_subscriber.google_state_expires_at = None - else: - db_subscriber.google_state_expires_at = datetime.now() + timedelta(minutes=3) - - db.commit() - db.refresh(db_subscriber) - return db_subscriber - - -def get_connections_limit(db: Session, subscriber_id: int): - """return the number of allowed connections for given subscriber or -1 for unlimited connections""" - db_subscriber = get_subscriber(db, subscriber_id) - mapping = { - models.SubscriberLevel.basic: int(os.getenv("TIER_BASIC_CALENDAR_LIMIT")), - models.SubscriberLevel.plus: int(os.getenv("TIER_PLUS_CALENDAR_LIMIT")), - models.SubscriberLevel.pro: int(os.getenv("TIER_PRO_CALENDAR_LIMIT")), - models.SubscriberLevel.admin: -1, - } - return mapping[db_subscriber.level] - - -def verify_subscriber_link(db: Session, url: str): - """Check if a given url is a valid signed subscriber profile link - Return subscriber if valid. - """ - # Look for a followed by an optional signature that ends the string - pattern = r"[\/]([\w\d\-_\.\@]+)[\/]?([\w\d]*)[\/]?$" - match = re.findall(pattern, url) - - if match is None or len(match) == 0: - return False - - # Flatten - match = match[0] - clean_url = url - - username = match[0] - signature = None - if len(match) > 1: - signature = match[1] - clean_url = clean_url.replace(signature, "") - - subscriber = get_subscriber_by_username(db, username) - if not subscriber: - return False - - clean_url_with_short_link = clean_url + f"{subscriber.short_link_hash}" - signed_signature = sign_url(clean_url_with_short_link) - - # Verify the signature matches the incoming one - if signed_signature == signature: - return subscriber - return False - - -""" CALENDAR repository functions -""" - - -def calendar_exists(db: Session, calendar_id: int): - """true if calendar of given id exists""" - return True if db.get(models.Calendar, calendar_id) is not None else False - - -def calendar_is_owned(db: Session, calendar_id: int, subscriber_id: int): - """check if calendar belongs to subscriber""" - return ( - db.query(models.Calendar) - .filter(models.Calendar.id == calendar_id, models.Calendar.owner_id == subscriber_id) - .first() - is not None - ) - - -def get_calendar(db: Session, calendar_id: int): - """retrieve calendar by id""" - return db.get(models.Calendar, calendar_id) - - -def calendar_is_connected(db: Session, calendar_id: int): - """true if calendar of given id exists""" - return get_calendar(db, calendar_id).connected - - -def get_calendar_by_url(db: Session, url: str): - """retrieve calendar by calendar url""" - return db.query(models.Calendar).filter(models.Calendar.url == url).first() - - -def get_calendars_by_subscriber(db: Session, subscriber_id: int, include_unconnected: bool = True): - """retrieve list of calendars by owner id""" - query = db.query(models.Calendar).filter(models.Calendar.owner_id == subscriber_id) - - if not include_unconnected: - query = query.filter(models.Calendar.connected == 1) - - return query.all() - - -def create_subscriber_calendar(db: Session, calendar: schemas.CalendarConnection, subscriber_id: int): - """create new calendar for owner, if not already existing""" - db_calendar = models.Calendar(**calendar.dict(), owner_id=subscriber_id) - subscriber_calendars = get_calendars_by_subscriber(db, subscriber_id) - subscriber_calendar_urls = [c.url for c in subscriber_calendars] - # check if subscriber already holds this calendar by url - if db_calendar.url in subscriber_calendar_urls: - raise HTTPException(status_code=403, detail="Calendar already exists") - # add new calendar - db.add(db_calendar) - db.commit() - db.refresh(db_calendar) - return db_calendar - - -def update_subscriber_calendar(db: Session, calendar: schemas.CalendarConnection, calendar_id: int): - """update existing calendar by id""" - db_calendar = get_calendar(db, calendar_id) - - # list of all attributes that must never be updated - # # because they have dedicated update functions for security reasons - ignore = ["connected", "connected_at"] - # list of all attributes that will keep their current value if None is passed - keep_if_none = ["password"] - - for key, value in calendar: - # skip update, if attribute is ignored or current value should be kept if given value is falsey/empty - if key in ignore or (key in keep_if_none and (not value or len(str(value)) == 0)): - continue - - setattr(db_calendar, key, value) - - db.commit() - db.refresh(db_calendar) - return db_calendar - - -def update_subscriber_calendar_connection(db: Session, is_connected: bool, calendar_id: int): - """Updates the connected status of a calendar""" - db_calendar = get_calendar(db, calendar_id) - # check subscription limitation on connecting - if is_connected: - subscriber_calendars = get_calendars_by_subscriber(db, db_calendar.owner_id) - connected_calendars = [calendar for calendar in subscriber_calendars if calendar.connected] - limit = get_connections_limit(db=db, subscriber_id=db_calendar.owner_id) - if limit > 0 and len(connected_calendars) >= limit: - raise HTTPException( - status_code=403, detail="Allowed number of connected calendars has been reached for this subscription" - ) - if not db_calendar.connected: - db_calendar.connected_at = datetime.now() - elif db_calendar.connected and is_connected is False: - db_calendar.connected_at = None - db_calendar.connected = is_connected - db.commit() - db.refresh(db_calendar) - return db_calendar - - -def update_or_create_subscriber_calendar( - db: Session, calendar: schemas.CalendarConnection, calendar_url: str, subscriber_id: int -): - """update or create a subscriber calendar""" - subscriber_calendar = get_calendar_by_url(db, calendar_url) - - if subscriber_calendar is None: - return create_subscriber_calendar(db, calendar, subscriber_id) - - return update_subscriber_calendar(db, calendar, subscriber_calendar.id) - - -def delete_subscriber_calendar(db: Session, calendar_id: int): - """remove existing calendar by id""" - db_calendar = get_calendar(db, calendar_id) - db.delete(db_calendar) - db.commit() - return db_calendar - - -def delete_subscriber_calendar_by_subscriber_id(db: Session, subscriber_id: int): - """Delete all calendars by subscriber""" - calendars = get_calendars_by_subscriber(db, subscriber_id=subscriber_id) - for calendar in calendars: - delete_subscriber_calendar(db, calendar_id=calendar.id) - return True - - -""" APPOINTMENT repository functions -""" - - -def create_calendar_appointment(db: Session, appointment: schemas.AppointmentFull, slots: list[schemas.SlotBase]): - """create new appointment with slots for calendar""" - db_appointment = models.Appointment(**appointment.dict()) - db.add(db_appointment) - db.commit() - db.refresh(db_appointment) - add_appointment_slots(db, slots, db_appointment.id) - return db_appointment - - -def get_appointment(db: Session, appointment_id: int): - """retrieve appointment by id (private)""" - if appointment_id: - return db.get(models.Appointment, appointment_id) - return None - - -def get_public_appointment(db: Session, slug: str): - """retrieve appointment by appointment slug (public)""" - if slug: - return db.query(models.Appointment).filter(models.Appointment.slug == slug).first() - return None - - -def get_appointments_by_subscriber(db: Session, subscriber_id: int): - """retrieve list of appointments by owner id""" - return db.query(models.Appointment).join(models.Calendar).filter(models.Calendar.owner_id == subscriber_id).all() - - -def appointment_is_owned(db: Session, appointment_id: int, subscriber_id: int): - """check if appointment belongs to subscriber""" - db_appointment = get_appointment(db, appointment_id) - return calendar_is_owned(db, db_appointment.calendar_id, subscriber_id) - - -def appointment_has_slot(db: Session, appointment_id: int, slot_id: int): - """check if appointment belongs to subscriber""" - db_slot = get_slot(db, slot_id) - return db_slot and db_slot.appointment_id == appointment_id - - -def update_calendar_appointment( - db: Session, - appointment: schemas.AppointmentFull, - slots: list[schemas.SlotBase], - appointment_id: int, -): - """update existing appointment by id""" - db_appointment = get_appointment(db, appointment_id) - for key, value in appointment: - setattr(db_appointment, key, value) - db.commit() - db.refresh(db_appointment) - delete_appointment_slots(db, appointment_id) - add_appointment_slots(db, slots, appointment_id) - return db_appointment - - -def delete_calendar_appointment(db: Session, appointment_id: int): - """remove existing appointment by id""" - db_appointment = get_appointment(db, appointment_id) - db.delete(db_appointment) - db.commit() - return db_appointment - - -def delete_calendar_appointments_by_subscriber_id(db: Session, subscriber_id: int): - """Delete all appointments by subscriber""" - appointments = get_appointments_by_subscriber(db, subscriber_id=subscriber_id) - for appointment in appointments: - delete_calendar_appointment(db, appointment_id=appointment.id) - return True - - -""" SLOT repository functions -""" - - -def get_slot(db: Session, slot_id: int): - """retrieve slot by id""" - if slot_id: - return db.get(models.Slot, slot_id) - return None - - -def get_slots_by_subscriber(db: Session, subscriber_id: int): - """retrieve slot by subscriber id""" - - # We need to walk through Calendars to attach Appointments, and Appointments to get Slots - return ( - db.query(models.Slot) - .join(models.Appointment) - .join(models.Calendar) - .filter(models.Calendar.owner_id == subscriber_id) - .filter(models.Appointment.calendar_id == models.Calendar.id) - .filter(models.Slot.appointment_id == models.Appointment.id) - .all() - ) - - -def add_appointment_slots(db: Session, slots: list[schemas.SlotBase], appointment_id: int): - """create new slots for appointment of given id""" - for slot in slots: - db_slot = models.Slot(**slot.dict()) - db_slot.appointment_id = appointment_id - db.add(db_slot) - db.commit() - return slots - - -def delete_appointment_slots(db: Session, appointment_id: int): - """delete all slots for appointment of given id""" - return db.query(models.Slot).filter(models.Slot.appointment_id == appointment_id).delete() - - -def delete_appointment_slots_by_subscriber_id(db: Session, subscriber_id: int): - """Delete all slots by subscriber""" - slots = get_slots_by_subscriber(db, subscriber_id) - - for slot in slots: - db.delete(slot) - db.commit() - - return True - - -def update_slot(db: Session, slot_id: int, attendee: schemas.Attendee): - """update existing slot by id and create corresponding attendee""" - # create attendee - db_attendee = models.Attendee(**attendee.dict()) - db.add(db_attendee) - db.commit() - db.refresh(db_attendee) - # update slot - db_slot = get_slot(db, slot_id) - # TODO: additionally handle subscriber_id here for already logged in users - setattr(db_slot, "attendee_id", db_attendee.id) - db.commit() - return db_attendee - - -def slot_is_available(db: Session, slot_id: int): - """check if slot is still available""" - db_slot = get_slot(db, slot_id) - return db_slot and not db_slot.attendee_id and not db_slot.subscriber_id - - -"""SCHEDULES repository functions -""" - - -def create_calendar_schedule(db: Session, schedule: schemas.ScheduleBase): - """create new schedule with slots for calendar""" - db_schedule = models.Schedule(**schedule.dict()) - db.add(db_schedule) - db.commit() - db.refresh(db_schedule) - return db_schedule - - -def get_schedules_by_subscriber(db: Session, subscriber_id: int): - """Get schedules by subscriber id""" - return ( - db.query(models.Schedule) - .join(models.Calendar, models.Schedule.calendar_id == models.Calendar.id) - .filter(models.Calendar.owner_id == subscriber_id) - .all() - ) - - -def get_schedule(db: Session, schedule_id: int): - """retrieve schedule by id""" - if schedule_id: - return db.get(models.Schedule, schedule_id) - return None - - -def schedule_is_owned(db: Session, schedule_id: int, subscriber_id: int): - """check if the given schedule belongs to subscriber""" - schedules = get_schedules_by_subscriber(db, subscriber_id) - return any(s.id == schedule_id for s in schedules) - - -def schedule_exists(db: Session, schedule_id: int): - """true if schedule of given id exists""" - return True if get_schedule(db, schedule_id) is not None else False - - -def update_calendar_schedule(db: Session, schedule: schemas.ScheduleBase, schedule_id: int): - """update existing schedule by id""" - db_schedule = get_schedule(db, schedule_id) - for key, value in schedule: - setattr(db_schedule, key, value) - db.commit() - db.refresh(db_schedule) - return db_schedule - - -def get_availability_by_schedule(db: Session, schedule_id: int): - """retrieve availability by schedule id""" - return db.query(models.Availability).filter(models.Availability.schedule_id == schedule_id).all() +"""Module: repo + +Repository providing CRUD functions for all database models. +""" +import os +import re +import logging +from datetime import timedelta, datetime + +from fastapi import HTTPException +from sqlalchemy.orm import Session +from . import models, schemas +from ..controller.auth import sign_url + + +"""ATTENDEES repository functions +""" + + +def get_attendees_by_subscriber(db: Session, subscriber_id: int): + """For use with the data download. Get attendees by subscriber id.""" + # We need to walk through Calendars to attach Appointments, and Appointments to get Slots + slots = ( + db.query(models.Slot) + .join(models.Appointment) + .join(models.Calendar) + .filter(models.Calendar.owner_id == subscriber_id) + .filter(models.Appointment.calendar_id == models.Calendar.id) + .filter(models.Slot.appointment_id == models.Appointment.id) + .all() + ) + + attendee_ids = list(map(lambda slot: slot.attendee_id if slot.attendee_id is not None else None, slots)) + attendee_ids = filter(lambda attendee: attendee is not None, attendee_ids) + return db.query(models.Attendee).filter(models.Attendee.id.in_(attendee_ids)).all() + + +def delete_attendees_by_subscriber(db: Session, subscriber_id: int): + """Delete all attendees by subscriber""" + attendees = get_attendees_by_subscriber(db, subscriber_id) + + for attendee in attendees: + db.delete(attendee) + db.commit() + + return True + + +""" SUBSCRIBERS repository functions +""" + + +def get_subscriber(db: Session, subscriber_id: int): + """retrieve subscriber by id""" + return db.get(models.Subscriber, subscriber_id) + + +def get_subscriber_by_email(db: Session, email: str): + """retrieve subscriber by email""" + return db.query(models.Subscriber).filter(models.Subscriber.email == email).first() + + +def get_subscriber_by_username(db: Session, username: str): + """retrieve subscriber by username""" + return db.query(models.Subscriber).filter(models.Subscriber.username == username).first() + + +def get_subscriber_by_appointment(db: Session, appointment_id: int): + """retrieve appointment by subscriber username and appointment slug (public)""" + if appointment_id: + return ( + db.query(models.Subscriber) + .join(models.Calendar) + .join(models.Appointment) + .filter(models.Appointment.id == appointment_id) + .first() + ) + return None + + +def get_subscriber_by_google_state(db: Session, state: str): + """retrieve subscriber by google state, you'll have to manually check the google_state_expire_at!""" + if state is None: + return None + return db.query(models.Subscriber).filter(models.Subscriber.google_state == state).first() + + +def create_subscriber(db: Session, subscriber: schemas.SubscriberBase): + """create new subscriber""" + db_subscriber = models.Subscriber(**subscriber.dict()) + db.add(db_subscriber) + db.commit() + db.refresh(db_subscriber) + return db_subscriber + + +def update_subscriber(db: Session, data: schemas.SubscriberIn, subscriber_id: int): + """update all subscriber attributes, they can edit themselves""" + db_subscriber = get_subscriber(db, subscriber_id) + for key, value in data: + if value is not None: + setattr(db_subscriber, key, value) + db.commit() + db.refresh(db_subscriber) + return db_subscriber + + +def delete_subscriber(db: Session, subscriber: models.Subscriber): + """Delete a subscriber by subscriber id""" + db.delete(subscriber) + db.commit() + return True + + +def set_subscriber_google_tkn(db: Session, tkn: str, subscriber_id: int): + """update all subscriber attributes, they can edit themselves""" + db_subscriber = get_subscriber(db, subscriber_id) + db_subscriber.google_tkn = tkn + db.commit() + db.refresh(db_subscriber) + return db_subscriber + + +def set_subscriber_google_state(db: Session, state: str | None, subscriber_id: int): + """temp store the google state so we can refer to it when we get back""" + db_subscriber = get_subscriber(db, subscriber_id) + db_subscriber.google_state = state + + if state is None: + db_subscriber.google_state_expires_at = None + else: + db_subscriber.google_state_expires_at = datetime.now() + timedelta(minutes=3) + + db.commit() + db.refresh(db_subscriber) + return db_subscriber + + +def get_connections_limit(db: Session, subscriber_id: int): + """return the number of allowed connections for given subscriber or -1 for unlimited connections""" + db_subscriber = get_subscriber(db, subscriber_id) + mapping = { + models.SubscriberLevel.basic: int(os.getenv("TIER_BASIC_CALENDAR_LIMIT")), + models.SubscriberLevel.plus: int(os.getenv("TIER_PLUS_CALENDAR_LIMIT")), + models.SubscriberLevel.pro: int(os.getenv("TIER_PRO_CALENDAR_LIMIT")), + models.SubscriberLevel.admin: -1, + } + return mapping[db_subscriber.level] + + +def verify_subscriber_link(db: Session, url: str): + """Check if a given url is a valid signed subscriber profile link + Return subscriber if valid. + """ + # Look for a followed by an optional signature that ends the string + pattern = r"[\/]([\w\d\-_\.\@]+)[\/]?([\w\d]*)[\/]?$" + match = re.findall(pattern, url) + + if match is None or len(match) == 0: + return False + + # Flatten + match = match[0] + clean_url = url + + username = match[0] + signature = None + if len(match) > 1: + signature = match[1] + clean_url = clean_url.replace(signature, "") + + subscriber = get_subscriber_by_username(db, username) + if not subscriber: + return False + + clean_url_with_short_link = clean_url + f"{subscriber.short_link_hash}" + signed_signature = sign_url(clean_url_with_short_link) + + # Verify the signature matches the incoming one + if signed_signature == signature: + return subscriber + return False + + +""" CALENDAR repository functions +""" + + +def calendar_exists(db: Session, calendar_id: int): + """true if calendar of given id exists""" + return True if db.get(models.Calendar, calendar_id) is not None else False + + +def calendar_is_owned(db: Session, calendar_id: int, subscriber_id: int): + """check if calendar belongs to subscriber""" + return ( + db.query(models.Calendar) + .filter(models.Calendar.id == calendar_id, models.Calendar.owner_id == subscriber_id) + .first() + is not None + ) + + +def get_calendar(db: Session, calendar_id: int): + """retrieve calendar by id""" + return db.get(models.Calendar, calendar_id) + + +def calendar_is_connected(db: Session, calendar_id: int): + """true if calendar of given id exists""" + return get_calendar(db, calendar_id).connected + + +def get_calendar_by_url(db: Session, url: str): + """retrieve calendar by calendar url""" + return db.query(models.Calendar).filter(models.Calendar.url == url).first() + + +def get_calendars_by_subscriber(db: Session, subscriber_id: int, include_unconnected: bool = True): + """retrieve list of calendars by owner id""" + query = db.query(models.Calendar).filter(models.Calendar.owner_id == subscriber_id) + + if not include_unconnected: + query = query.filter(models.Calendar.connected == 1) + + return query.all() + + +def create_subscriber_calendar(db: Session, calendar: schemas.CalendarConnection, subscriber_id: int): + """create new calendar for owner, if not already existing""" + db_calendar = models.Calendar(**calendar.dict(), owner_id=subscriber_id) + subscriber_calendars = get_calendars_by_subscriber(db, subscriber_id) + subscriber_calendar_urls = [c.url for c in subscriber_calendars] + # check if subscriber already holds this calendar by url + if db_calendar.url in subscriber_calendar_urls: + raise HTTPException(status_code=403, detail="Calendar already exists") + # add new calendar + db.add(db_calendar) + db.commit() + db.refresh(db_calendar) + return db_calendar + + +def update_subscriber_calendar(db: Session, calendar: schemas.CalendarConnection, calendar_id: int): + """update existing calendar by id""" + db_calendar = get_calendar(db, calendar_id) + + # list of all attributes that must never be updated + # # because they have dedicated update functions for security reasons + ignore = ["connected", "connected_at"] + # list of all attributes that will keep their current value if None is passed + keep_if_none = ["password"] + + for key, value in calendar: + # skip update, if attribute is ignored or current value should be kept if given value is falsey/empty + if key in ignore or (key in keep_if_none and (not value or len(str(value)) == 0)): + continue + + setattr(db_calendar, key, value) + + db.commit() + db.refresh(db_calendar) + return db_calendar + + +def update_subscriber_calendar_connection(db: Session, is_connected: bool, calendar_id: int): + """Updates the connected status of a calendar""" + db_calendar = get_calendar(db, calendar_id) + # check subscription limitation on connecting + if is_connected: + subscriber_calendars = get_calendars_by_subscriber(db, db_calendar.owner_id) + connected_calendars = [calendar for calendar in subscriber_calendars if calendar.connected] + limit = get_connections_limit(db=db, subscriber_id=db_calendar.owner_id) + if limit > 0 and len(connected_calendars) >= limit: + raise HTTPException( + status_code=403, detail="Allowed number of connected calendars has been reached for this subscription" + ) + if not db_calendar.connected: + db_calendar.connected_at = datetime.now() + elif db_calendar.connected and is_connected is False: + db_calendar.connected_at = None + db_calendar.connected = is_connected + db.commit() + db.refresh(db_calendar) + return db_calendar + + +def update_or_create_subscriber_calendar( + db: Session, calendar: schemas.CalendarConnection, calendar_url: str, subscriber_id: int +): + """update or create a subscriber calendar""" + subscriber_calendar = get_calendar_by_url(db, calendar_url) + + if subscriber_calendar is None: + return create_subscriber_calendar(db, calendar, subscriber_id) + + return update_subscriber_calendar(db, calendar, subscriber_calendar.id) + + +def delete_subscriber_calendar(db: Session, calendar_id: int): + """remove existing calendar by id""" + db_calendar = get_calendar(db, calendar_id) + db.delete(db_calendar) + db.commit() + return db_calendar + + +def delete_subscriber_calendar_by_subscriber_id(db: Session, subscriber_id: int): + """Delete all calendars by subscriber""" + calendars = get_calendars_by_subscriber(db, subscriber_id=subscriber_id) + for calendar in calendars: + delete_subscriber_calendar(db, calendar_id=calendar.id) + return True + + +""" APPOINTMENT repository functions +""" + + +def create_calendar_appointment(db: Session, appointment: schemas.AppointmentFull, slots: list[schemas.SlotBase]): + """create new appointment with slots for calendar""" + db_appointment = models.Appointment(**appointment.dict()) + db.add(db_appointment) + db.commit() + db.refresh(db_appointment) + add_appointment_slots(db, slots, db_appointment.id) + return db_appointment + + +def get_appointment(db: Session, appointment_id: int): + """retrieve appointment by id (private)""" + if appointment_id: + return db.get(models.Appointment, appointment_id) + return None + + +def get_public_appointment(db: Session, slug: str): + """retrieve appointment by appointment slug (public)""" + if slug: + return db.query(models.Appointment).filter(models.Appointment.slug == slug).first() + return None + + +def get_appointments_by_subscriber(db: Session, subscriber_id: int): + """retrieve list of appointments by owner id""" + return db.query(models.Appointment).join(models.Calendar).filter(models.Calendar.owner_id == subscriber_id).all() + + +def appointment_is_owned(db: Session, appointment_id: int, subscriber_id: int): + """check if appointment belongs to subscriber""" + db_appointment = get_appointment(db, appointment_id) + return calendar_is_owned(db, db_appointment.calendar_id, subscriber_id) + + +def appointment_has_slot(db: Session, appointment_id: int, slot_id: int): + """check if appointment belongs to subscriber""" + db_slot = get_slot(db, slot_id) + return db_slot and db_slot.appointment_id == appointment_id + + +def update_calendar_appointment( + db: Session, + appointment: schemas.AppointmentFull, + slots: list[schemas.SlotBase], + appointment_id: int, +): + """update existing appointment by id""" + db_appointment = get_appointment(db, appointment_id) + for key, value in appointment: + setattr(db_appointment, key, value) + db.commit() + db.refresh(db_appointment) + delete_appointment_slots(db, appointment_id) + add_appointment_slots(db, slots, appointment_id) + return db_appointment + + +def delete_calendar_appointment(db: Session, appointment_id: int): + """remove existing appointment by id""" + db_appointment = get_appointment(db, appointment_id) + db.delete(db_appointment) + db.commit() + return db_appointment + + +def delete_calendar_appointments_by_subscriber_id(db: Session, subscriber_id: int): + """Delete all appointments by subscriber""" + appointments = get_appointments_by_subscriber(db, subscriber_id=subscriber_id) + for appointment in appointments: + delete_calendar_appointment(db, appointment_id=appointment.id) + return True + + +""" SLOT repository functions +""" + + +def get_slot(db: Session, slot_id: int): + """retrieve slot by id""" + if slot_id: + return db.get(models.Slot, slot_id) + return None + + +def get_slots_by_subscriber(db: Session, subscriber_id: int): + """retrieve slot by subscriber id""" + + # We need to walk through Calendars to attach Appointments, and Appointments to get Slots + return ( + db.query(models.Slot) + .join(models.Appointment) + .join(models.Calendar) + .filter(models.Calendar.owner_id == subscriber_id) + .filter(models.Appointment.calendar_id == models.Calendar.id) + .filter(models.Slot.appointment_id == models.Appointment.id) + .all() + ) + + +def add_appointment_slots(db: Session, slots: list[schemas.SlotBase], appointment_id: int): + """create new slots for appointment of given id""" + for slot in slots: + db_slot = models.Slot(**slot.dict()) + db_slot.appointment_id = appointment_id + db.add(db_slot) + db.commit() + return slots + + +def add_schedule_slot(db: Session, slot: schemas.SlotBase, schedule_id: int): + """create new slot for schedule of given id""" + db_slot = models.Slot(**slot.dict()) + db_slot.schedule_id = schedule_id + db.add(db_slot) + db.commit() + db.refresh(db_slot) + return db_slot + + +def schedule_slot_exists(db: Session, slot: schemas.SlotBase, schedule_id: int): + """check if given slot already exists for schedule of given id""" + db_slot = ( + db.query(models.Slot) + .filter(models.Slot.schedule_id == schedule_id) + .filter(models.Slot.start == slot.start) + .filter(models.Slot.duration == slot.duration) + .filter(models.Slot.booking_status != models.BookingStatus.none) + .first() + ) + return db_slot is not None + + +def book_slot(db: Session, slot_id: int): + """update booking status for slot of given id""" + db_slot = get_slot(db, slot_id) + db_slot.booking_status = models.BookingStatus.booked + db.commit() + db.refresh(db_slot) + return db_slot + + +def delete_appointment_slots(db: Session, appointment_id: int): + """delete all slots for appointment of given id""" + return db.query(models.Slot).filter(models.Slot.appointment_id == appointment_id).delete() + + +def delete_appointment_slots_by_subscriber_id(db: Session, subscriber_id: int): + """Delete all slots by subscriber""" + slots = get_slots_by_subscriber(db, subscriber_id) + + for slot in slots: + db.delete(slot) + db.commit() + + return True + + +def update_slot(db: Session, slot_id: int, attendee: schemas.Attendee): + """update existing slot by id and create corresponding attendee""" + # create attendee + db_attendee = models.Attendee(**attendee.dict()) + db.add(db_attendee) + db.commit() + db.refresh(db_attendee) + # update slot + db_slot = get_slot(db, slot_id) + # TODO: additionally handle subscriber_id here for already logged in users + setattr(db_slot, "attendee_id", db_attendee.id) + db.commit() + return db_attendee + + +def delete_slot(db: Session, slot_id: int): + """remove existing slot by id""" + db_slot = get_slot(db, slot_id) + db.delete(db_slot) + db.commit() + return db_slot + + +def slot_is_available(db: Session, slot_id: int): + """check if slot is still available for booking""" + slot = get_slot(db, slot_id) + isAttended = slot.attendee_id or slot.subscriber_id + if slot.appointment: + return slot and not isAttended + if slot.schedule: + return slot and slot.booking_status == models.BookingStatus.requested + return False + + +"""SCHEDULES repository functions +""" + + +def create_calendar_schedule(db: Session, schedule: schemas.ScheduleBase): + """create new schedule with slots for calendar""" + db_schedule = models.Schedule(**schedule.dict()) + db.add(db_schedule) + db.commit() + db.refresh(db_schedule) + return db_schedule + + +def get_schedules_by_subscriber(db: Session, subscriber_id: int): + """Get schedules by subscriber id""" + return ( + db.query(models.Schedule) + .join(models.Calendar, models.Schedule.calendar_id == models.Calendar.id) + .filter(models.Calendar.owner_id == subscriber_id) + .all() + ) + + +def get_schedule(db: Session, schedule_id: int): + """retrieve schedule by id""" + if schedule_id: + return db.get(models.Schedule, schedule_id) + return None + + +def schedule_is_owned(db: Session, schedule_id: int, subscriber_id: int): + """check if the given schedule belongs to subscriber""" + schedules = get_schedules_by_subscriber(db, subscriber_id) + return any(s.id == schedule_id for s in schedules) + + +def schedule_exists(db: Session, schedule_id: int): + """true if schedule of given id exists""" + return True if get_schedule(db, schedule_id) is not None else False + + +def update_calendar_schedule(db: Session, schedule: schemas.ScheduleBase, schedule_id: int): + """update existing schedule by id""" + db_schedule = get_schedule(db, schedule_id) + for key, value in schedule: + setattr(db_schedule, key, value) + db.commit() + db.refresh(db_schedule) + return db_schedule + + +def get_availability_by_schedule(db: Session, schedule_id: int): + """retrieve availability by schedule id""" + return db.query(models.Availability).filter(models.Availability.schedule_id == schedule_id).all() + + +def schedule_has_slot(db: Session, schedule_id: int, slot_id: int): + """check if slot belongs to schedule""" + db_slot = get_slot(db, slot_id) + return db_slot and db_slot.schedule_id == schedule_id diff --git a/backend/src/appointment/database/schemas.py b/backend/src/appointment/database/schemas.py index 0f90193a..483af9d4 100644 --- a/backend/src/appointment/database/schemas.py +++ b/backend/src/appointment/database/schemas.py @@ -1,259 +1,270 @@ -"""Module: schemas - -Definitions of valid data shapes for database and query models. -""" -from datetime import datetime, date, time -from pydantic import BaseModel, Field -from .models import ( - SubscriberLevel, - AppointmentStatus, - LocationType, - CalendarProvider, - DayOfWeek, - random_slug, -) - - -""" ATTENDEE model schemas -""" - - -class AttendeeBase(BaseModel): - email: str - name: str | None = None - - -class Attendee(AttendeeBase): - id: int - - class Config: - orm_mode = True - - -""" SLOT model schemas -""" - - -class SlotBase(BaseModel): - start: datetime - duration: int | None = None - attendee_id: int | None = None - - -class Slot(SlotBase): - id: int - appointment_id: int - subscriber_id: int | None = None - time_updated: datetime | None = None - attendee: Attendee | None = None - - class Config: - orm_mode = True - - -class SlotOut(SlotBase): - id: int | None = None - - -class SlotAttendee(BaseModel): - slot_id: int - attendee: AttendeeBase - - -class AvailabilitySlotAttendee(BaseModel): - slot: SlotBase - attendee: AttendeeBase - - -""" APPOINTMENT model schemas -""" - - -class AppointmentBase(BaseModel): - title: str - details: str | None = None - slug: str | None = Field(default_factory=random_slug) - - -class AppointmentFull(AppointmentBase): - calendar_id: int - duration: int | None = None - location_type: LocationType | None = LocationType.inperson - location_suggestions: str | None = None - location_selected: str | None = None - location_name: str | None = None - location_url: str | None = None - location_phone: str | None = None - keep_open: bool | None = True - status: AppointmentStatus | None = AppointmentStatus.draft - - -class Appointment(AppointmentFull): - id: int - time_created: datetime | None = None - time_updated: datetime | None = None - slots: list[Slot] = [] - - class Config: - orm_mode = True - - -class AppointmentOut(AppointmentBase): - id: int | None = None - owner_name: str | None = None - slots: list[SlotOut] = [] - - -""" SCHEDULE model schemas -""" - - -class AvailabilityBase(BaseModel): - schedule_id: int - day_of_week: DayOfWeek - start_time: datetime | None = None - end_time: datetime | None = None - min_time_before_meeting: int - slot_duration: int | None = None - - -class Availability(AvailabilityBase): - id: int - time_created: datetime | None = None - time_updated: datetime | None = None - - class Config: - orm_mode = True - - -class ScheduleBase(BaseModel): - active: bool | None = True - name: str - calendar_id: int - location_type: LocationType | None = LocationType.inperson - location_url: str | None = None - details: str | None = None - start_date: date | None = None - end_date: date | None = None - start_time: time | None = None - end_time: time | None = None - earliest_booking: int | None = None - farthest_booking: int | None = None - weekdays: list[int] | None = [1, 2, 3, 4, 5] - slot_duration: int | None = None - - class Config: - json_encoders = { - time: lambda t: t.strftime("%H:%M"), - } - - -class Schedule(ScheduleBase): - id: int - time_created: datetime | None = None - time_updated: datetime | None = None - availabilities: list[Availability] = [] - - class Config: - orm_mode = True - - -""" CALENDAR model schemas -""" - - -class CalendarBase(BaseModel): - title: str | None = None - color: str | None = None - connected: bool | None = None - - -class CalendarConnectionOut(CalendarBase): - provider: CalendarProvider | None = CalendarProvider.caldav - url: str - user: str - - -class CalendarConnection(CalendarConnectionOut): - password: str - - -class Calendar(CalendarConnection): - id: int - owner_id: int - appointments: list[Appointment] = [] - schedules: list[Schedule] = [] - - class Config: - orm_mode = True - - -class CalendarOut(CalendarBase): - id: int - - -""" SUBSCRIBER model schemas -""" - - -class SubscriberIn(BaseModel): - timezone: str | None = None - username: str - name: str | None = None - - -class SubscriberBase(SubscriberIn): - email: str - level: SubscriberLevel | None = SubscriberLevel.basic - - -class SubscriberAuth(SubscriberBase): - google_tkn: str | None = None - google_state: str | None = None - google_state_expires_at: datetime | None = None - short_link_hash: str | None = None - - -class Subscriber(SubscriberAuth): - id: int - calendars: list[Calendar] = [] - slots: list[Slot] = [] - - class Config: - orm_mode = True - - -""" other schemas used for requests or data migration -""" - - -class AppointmentSlots(BaseModel): - appointment: AppointmentFull - slots: list[SlotBase] = [] - - -class EventLocation(BaseModel): - type: LocationType | None = LocationType.inperson - suggestions: str | None = None - selected: str | None = None - name: str | None = None - url: str | None = None - phone: str | None = None - - -class Event(BaseModel): - title: str - start: str - end: str - all_day: bool | None = False - tentative: bool | None = False - description: str | None = None - calendar_title: str | None = None - calendar_color: str | None = None - location: EventLocation | None = None - - -class FileDownload(BaseModel): - name: str - content_type: str - data: str +"""Module: schemas + +Definitions of valid data shapes for database and query models. +""" +from datetime import datetime, date, time +from pydantic import BaseModel, Field +from .models import ( + AppointmentStatus, + BookingStatus, + CalendarProvider, + DayOfWeek, + LocationType, + random_slug, + SubscriberLevel, +) + + +""" ATTENDEE model schemas +""" + + +class AttendeeBase(BaseModel): + email: str + name: str | None = None + + +class Attendee(AttendeeBase): + id: int + + class Config: + orm_mode = True + + +""" SLOT model schemas +""" + + +class SlotBase(BaseModel): + start: datetime + duration: int | None = None + attendee_id: int | None = None + booking_tkn: str | None = None + booking_expires_at: datetime | None = None + booking_status: BookingStatus | None = BookingStatus.none + + +class Slot(SlotBase): + id: int + appointment_id: int + subscriber_id: int | None = None + time_updated: datetime | None = None + attendee: Attendee | None = None + + class Config: + orm_mode = True + + +class SlotOut(SlotBase): + id: int | None = None + + +class SlotAttendee(BaseModel): + slot_id: int + attendee: AttendeeBase + + +class AvailabilitySlotAttendee(BaseModel): + slot: SlotBase + attendee: AttendeeBase + + +""" APPOINTMENT model schemas +""" + + +class AppointmentBase(BaseModel): + title: str + details: str | None = None + slug: str | None = Field(default_factory=random_slug) + + +class AppointmentFull(AppointmentBase): + calendar_id: int + duration: int | None = None + location_type: LocationType | None = LocationType.inperson + location_suggestions: str | None = None + location_selected: str | None = None + location_name: str | None = None + location_url: str | None = None + location_phone: str | None = None + keep_open: bool | None = True + status: AppointmentStatus | None = AppointmentStatus.draft + + +class Appointment(AppointmentFull): + id: int + time_created: datetime | None = None + time_updated: datetime | None = None + slots: list[Slot] = [] + + class Config: + orm_mode = True + + +class AppointmentOut(AppointmentBase): + id: int | None = None + owner_name: str | None = None + slots: list[SlotOut] = [] + + +""" SCHEDULE model schemas +""" + + +class AvailabilityBase(BaseModel): + schedule_id: int + day_of_week: DayOfWeek + start_time: datetime | None = None + end_time: datetime | None = None + min_time_before_meeting: int + slot_duration: int | None = None + + +class Availability(AvailabilityBase): + id: int + time_created: datetime | None = None + time_updated: datetime | None = None + + class Config: + orm_mode = True + + +class ScheduleBase(BaseModel): + active: bool | None = True + name: str + calendar_id: int + location_type: LocationType | None = LocationType.inperson + location_url: str | None = None + details: str | None = None + start_date: date | None = None + end_date: date | None = None + start_time: time | None = None + end_time: time | None = None + earliest_booking: int | None = None + farthest_booking: int | None = None + weekdays: list[int] | None = [1, 2, 3, 4, 5] + slot_duration: int | None = None + + class Config: + json_encoders = { + time: lambda t: t.strftime("%H:%M"), + } + + +class Schedule(ScheduleBase): + id: int + time_created: datetime | None = None + time_updated: datetime | None = None + availabilities: list[Availability] = [] + + class Config: + orm_mode = True + + +""" CALENDAR model schemas +""" + + +class CalendarBase(BaseModel): + title: str | None = None + color: str | None = None + connected: bool | None = None + + +class CalendarConnectionOut(CalendarBase): + provider: CalendarProvider | None = CalendarProvider.caldav + url: str + user: str + + +class CalendarConnection(CalendarConnectionOut): + password: str + + +class Calendar(CalendarConnection): + id: int + owner_id: int + appointments: list[Appointment] = [] + schedules: list[Schedule] = [] + + class Config: + orm_mode = True + + +class CalendarOut(CalendarBase): + id: int + + +""" SUBSCRIBER model schemas +""" + + +class SubscriberIn(BaseModel): + timezone: str | None = None + username: str + name: str | None = None + + +class SubscriberBase(SubscriberIn): + email: str + level: SubscriberLevel | None = SubscriberLevel.basic + + +class SubscriberAuth(SubscriberBase): + google_tkn: str | None = None + google_state: str | None = None + google_state_expires_at: datetime | None = None + short_link_hash: str | None = None + + +class Subscriber(SubscriberAuth): + id: int + calendars: list[Calendar] = [] + slots: list[Slot] = [] + + class Config: + orm_mode = True + + +""" other schemas used for requests or data migration +""" + + +class AppointmentSlots(BaseModel): + appointment: AppointmentFull + slots: list[SlotBase] = [] + + +class AvailabilitySlotConfirmation(BaseModel): + slot_id: int + slot_token: str + owner_url: str + confirmed: bool | None = False + + +class EventLocation(BaseModel): + type: LocationType | None = LocationType.inperson + suggestions: str | None = None + selected: str | None = None + name: str | None = None + url: str | None = None + phone: str | None = None + + +class Event(BaseModel): + title: str + start: str + end: str + all_day: bool | None = False + tentative: bool | None = False + description: str | None = None + calendar_title: str | None = None + calendar_color: str | None = None + location: EventLocation | None = None + + +class FileDownload(BaseModel): + name: str + content_type: str + data: str diff --git a/backend/src/appointment/main.py b/backend/src/appointment/main.py old mode 100755 new mode 100644 diff --git a/backend/src/appointment/migrations/versions/2023_10_19_1535-2b1d96fb4058_extend_slots_table_for_.py b/backend/src/appointment/migrations/versions/2023_10_19_1535-2b1d96fb4058_extend_slots_table_for_.py new file mode 100644 index 00000000..6855476c --- /dev/null +++ b/backend/src/appointment/migrations/versions/2023_10_19_1535-2b1d96fb4058_extend_slots_table_for_.py @@ -0,0 +1,43 @@ +"""extend slots table for availability bookings + +Revision ID: 2b1d96fb4058 +Revises: 3789c9fd57c5 +Create Date: 2023-10-19 15:35:17.671137 + +""" +import os +from alembic import op +import sqlalchemy as sa +from sqlalchemy import DateTime +from sqlalchemy_utils import StringEncryptedType +from sqlalchemy_utils.types.encrypted.encrypted_type import AesEngine +from database.models import BookingStatus + + +def secret(): + return os.getenv("DB_SECRET") + + +# revision identifiers, used by Alembic. +revision = '2b1d96fb4058' +down_revision = '3789c9fd57c5' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column("slots", sa.Column("schedule_id", sa.Integer, sa.ForeignKey("schedules.id"))) + op.add_column( + "slots", + sa.Column("booking_tkn", StringEncryptedType(sa.String, secret, AesEngine, "pkcs5", length=512), index=False) + ) + op.add_column("slots", sa.Column("booking_expires_at", DateTime())) + op.add_column("slots", sa.Column("booking_status", sa.Enum(BookingStatus), default=BookingStatus.none)) + + +def downgrade() -> None: + op.drop_constraint("slots_ibfk_4", "slots", type_='foreignkey') + op.drop_column("slots", "schedule_id") + op.drop_column("slots", "booking_tkn") + op.drop_column("slots", "booking_expires_at") + op.drop_column("slots", "booking_status") diff --git a/backend/src/appointment/routes/api.py b/backend/src/appointment/routes/api.py index 6712a147..353461c8 100644 --- a/backend/src/appointment/routes/api.py +++ b/backend/src/appointment/routes/api.py @@ -14,7 +14,7 @@ from fastapi_auth0 import Auth0User from datetime import timedelta, timezone from ..controller.google_client import GoogleClient -from ..controller.auth import sign_url +from ..controller.auth import signed_url_by_subscriber from ..database.models import Subscriber, CalendarProvider from ..dependencies.google import get_google_client from ..dependencies.auth import get_subscriber, auth @@ -82,22 +82,7 @@ def get_my_signature(subscriber: Subscriber = Depends(get_subscriber)): """Retrieve a subscriber's signed short link""" if not subscriber: raise HTTPException(status_code=401, detail="No valid authentication credentials provided") - - short_url = os.getenv("SHORT_BASE_URL") - base_url = f"{os.getenv('FRONTEND_URL')}/user" - - # If we don't have a short url, then use the default url with /user added to it - if not short_url: - short_url = base_url - - # We sign with a different hash that the end-user doesn't have access to - # We also need to use the default url, as short urls are currently setup as a redirect - url = f"{base_url}/{subscriber.username}/{subscriber.short_link_hash}" - - signature = sign_url(url) - - # We return with the signed url signature - return {"url": f"{short_url}/{subscriber.username}/{signature}"} + return {"url": signed_url_by_subscriber(subscriber)} @router.post("/me/signature") @@ -181,7 +166,7 @@ def update_my_calendar( return schemas.CalendarOut(id=cal.id, title=cal.title, color=cal.color, connected=cal.connected) -@router.post("/cal/{id}/connect") +@router.post("/cal/{id}/connect", response_model=schemas.CalendarOut) def connect_my_calendar( id: int, db: Session = Depends(get_db), diff --git a/backend/src/appointment/routes/schedule.py b/backend/src/appointment/routes/schedule.py index 5f9864a9..9a4f83a2 100644 --- a/backend/src/appointment/routes/schedule.py +++ b/backend/src/appointment/routes/schedule.py @@ -1,15 +1,20 @@ from fastapi import APIRouter, Depends, HTTPException, Body import logging +import os from sqlalchemy.orm import Session from ..controller.calendar import CalDavConnector, Tools, GoogleConnector from ..controller.google_client import GoogleClient +from ..controller.mailer import ConfirmationMail, RejectionMail +from ..controller.auth import signed_url_by_subscriber from ..database import repo, schemas -from ..database.models import Subscriber, Schedule, CalendarProvider +from ..database.models import Subscriber, Schedule, CalendarProvider, random_slug, BookingStatus from ..dependencies.auth import get_subscriber from ..dependencies.database import get_db from ..dependencies.google import get_google_client -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone +from zoneinfo import ZoneInfo +from urllib.parse import quote_plus router = APIRouter() @@ -95,27 +100,10 @@ def read_schedule_availabilities( # calculate theoretically possible slots from schedule config availableSlots = Tools.available_slots_from_schedule(schedule) # get all events from all connected calendars in scheduled date range - existingEvents = [] calendars = repo.get_calendars_by_subscriber(db, subscriber.id, False) if not calendars or len(calendars) == 0: raise HTTPException(status_code=404, detail="No calendars found") - for calendar in calendars: - if calendar is None: - raise HTTPException(status_code=404, detail="Calendar not found") - if calendar.provider == CalendarProvider.google: - con = GoogleConnector( - db=db, - google_client=google_client, - calendar_id=calendar.user, - subscriber_id=subscriber.id, - google_tkn=subscriber.google_tkn, - ) - else: - con = CalDavConnector(calendar.url, calendar.user, calendar.password) - farthest_end = datetime.utcnow() + timedelta(minutes=schedule.farthest_booking) - start = schedule.start_date.strftime("%Y-%m-%d") - end = schedule.end_date.strftime("%Y-%m-%d") if schedule.end_date else farthest_end.strftime("%Y-%m-%d") - existingEvents.extend(con.list_events(start, end)) + existingEvents = Tools.existing_events_for_schedule(schedule, calendars, subscriber, google_client, db) actualSlots = Tools.events_set_difference(availableSlots, existingEvents) if not actualSlots or len(actualSlots) == 0: raise HTTPException(status_code=404, detail="No possible booking slots found") @@ -127,14 +115,13 @@ def read_schedule_availabilities( ) -@router.put("/public/availability", response_model=schemas.AvailabilitySlotAttendee) -def update_schedule_availability_slot( +@router.put("/public/availability/request") +def request_schedule_availability_slot( s_a: schemas.AvailabilitySlotAttendee, url: str = Body(..., embed=True), db: Session = Depends(get_db), - google_client: GoogleClient = Depends(get_google_client), ): - """endpoint to update a time slot for a schedule via public link and create an event in remote calendar""" + """endpoint to request a time slot for a schedule via public link and send confirmation mail to owner""" subscriber = repo.verify_subscriber_link(db, url) if not subscriber: raise HTTPException(status_code=401, detail="Invalid profile link") @@ -149,36 +136,122 @@ def update_schedule_availability_slot( # get calendar db_calendar = repo.get_calendar(db, calendar_id=schedule.calendar_id) if db_calendar is None: - raise HTTPException(status_code=404, detail="Calendar not found") - event = schemas.Event( - title=schedule.name, - start=s_a.slot.start.isoformat(), - end=(s_a.slot.start + timedelta(minutes=s_a.slot.duration)).isoformat(), - description=schedule.details, - location=schemas.EventLocation( - type=schedule.location_type, - url=schedule.location_url, - name=None, - ), + raise HTTPException(status_code=401, detail="Calendar not found") + # check if slot still available, might already be taken at this time + slot = schemas.SlotBase(**s_a.slot.dict()) + if repo.schedule_slot_exists(db, slot, schedule.id): + raise HTTPException(status_code=403, detail="Slot not available") + # create slot in db with token and expiration date + token = random_slug() + slot.booking_tkn = token + slot.booking_expires_at = datetime.now() + timedelta(days=1) + slot.booking_status = BookingStatus.requested + slot = repo.add_schedule_slot(db, slot, schedule.id) + # create attendee for this slot + attendee = repo.update_slot(db, slot.id, s_a.attendee) + # generate confirm and deny links with encoded booking token and signed owner url + url = f"{signed_url_by_subscriber(subscriber)}/confirm/{slot.id}/{token}" + # human readable date in subscribers timezone + # TODO: handle locale date representation + date = slot.start.replace(tzinfo=timezone.utc).astimezone(ZoneInfo(subscriber.timezone)).strftime("%c") + # send confirmation mail to owner + mail = ConfirmationMail( + f"{url}/1", + f"{url}/0", + attendee, + f"{date}, {slot.duration} minutes", + sender=f"noreply@{os.getenv('SMTP_URL')}", + to=subscriber.email ) - # create remote event - if db_calendar.provider == CalendarProvider.google: - con = GoogleConnector( - db=db, - google_client=google_client, - calendar_id=db_calendar.user, - subscriber_id=subscriber.id, - google_tkn=subscriber.google_tkn, + mail.send() + return True + + +@router.put("/public/availability/booking", response_model=schemas.AvailabilitySlotAttendee) +def request_schedule_availability_slot( + data: schemas.AvailabilitySlotConfirmation, + db: Session = Depends(get_db), + google_client: GoogleClient = Depends(get_google_client), +): + """endpoint to react to owners decision to a request of a time slot of his public link + if confirmed: create an event in remote calendar and send invitation mail + TODO: if denied: send information mail to bookee + """ + subscriber = repo.verify_subscriber_link(db, data.owner_url) + if not subscriber: + raise HTTPException(status_code=401, detail="Invalid profile link") + schedules = repo.get_schedules_by_subscriber(db, subscriber_id=subscriber.id) + try: + schedule = schedules[0] # for now we only process the first existing schedule + except IndexError: + raise HTTPException(status_code=404, detail="Schedule not found") + # check if schedule is enabled + if not schedule.active: + raise HTTPException(status_code=404, detail="Schedule not found") + # get calendar + calendar = repo.get_calendar(db, calendar_id=schedule.calendar_id) + if calendar is None: + raise HTTPException(status_code=404, detail="Calendar not found") + # get slot and check if slot exists and is not booked yet and token is the same + slot = repo.get_slot(db, data.slot_id) + if ( + not slot + or not repo.slot_is_available(db, slot.id) + or not repo.schedule_has_slot(db, schedule.id, slot.id) + or slot.booking_tkn != data.slot_token + ): + raise HTTPException(status_code=404, detail="Booking slot not found") + # TODO: check booking expiration date + # check if request was denied + if data.confirmed == False: + # human readable date in subscribers timezone + # TODO: handle locale date representation + date = slot.start.replace(tzinfo=timezone.utc).astimezone(ZoneInfo(subscriber.timezone)).strftime("%c") + # send rejection information to bookee + mail = RejectionMail( + owner=subscriber, + date=f"{date}, {slot.duration} minutes", + sender=f"noreply@{os.getenv('SMTP_URL')}", + to=slot.attendee.email ) + mail.send() + # delete the scheduled slot to make the time available again + repo.delete_slot(db, slot.id) + # otherwise, confirm slot and create event else: - con = CalDavConnector(db_calendar.url, db_calendar.user, db_calendar.password) - con.create_event(event=event, attendee=s_a.attendee, organizer=subscriber) + slot = repo.book_slot(db, slot.id) + event = schemas.Event( + title=schedule.name, + start=slot.start.replace(tzinfo=timezone.utc).isoformat(), + end=(slot.start.replace(tzinfo=timezone.utc) + timedelta(minutes=slot.duration)).isoformat(), + description=schedule.details, + location=schemas.EventLocation( + type=schedule.location_type, + url=schedule.location_url, + name=None, + ), + ) + # create remote event + if calendar.provider == CalendarProvider.google: + con = GoogleConnector( + db=db, + google_client=google_client, + calendar_id=calendar.user, + subscriber_id=subscriber.id, + google_tkn=subscriber.google_tkn, + ) + else: + con = CalDavConnector(calendar.url, calendar.user, calendar.password) + con.create_event(event=event, attendee=slot.attendee, organizer=subscriber) - # send mail with .ics attachment to attendee - appointment = schemas.AppointmentBase(title=schedule.name, details=schedule.details) - Tools().send_vevent(appointment, s_a.slot, subscriber, s_a.attendee) + # send mail with .ics attachment to attendee + appointment = schemas.AppointmentBase(title=schedule.name, details=schedule.details) + Tools().send_vevent(appointment, slot, subscriber, slot.attendee) - return s_a + return schemas.AvailabilitySlotAttendee( + slot=schemas.SlotBase(start=slot.start, duration=slot.duration), + attendee=schemas.AttendeeBase(email=slot.attendee.email, name=slot.attendee.name) + ) @router.put("/serve/ics", response_model=schemas.FileDownload) diff --git a/backend/src/appointment/templates/email/confirm.jinja2 b/backend/src/appointment/templates/email/confirm.jinja2 new file mode 100644 index 00000000..de43f357 --- /dev/null +++ b/backend/src/appointment/templates/email/confirm.jinja2 @@ -0,0 +1,38 @@ + + +

+ {{ attendee.name|title }} ({{ attendee.email }}) just requested this time slot from your schedule: {{ date }}. +

+

+ Click here to confirm the booking request:
+ Confirm Booking +

+

+ Or here if you want to deny it:
+ Deny Booking +

+

+ This message is sent from Thunderbird Appointment. +

+ + diff --git a/backend/src/appointment/templates/email/invite.jinja2 b/backend/src/appointment/templates/email/invite.jinja2 index 0cb90875..932ce49c 100644 --- a/backend/src/appointment/templates/email/invite.jinja2 +++ b/backend/src/appointment/templates/email/invite.jinja2 @@ -1,5 +1,5 @@ - - -

This message is sent from Appointment.

- + + +

This message is sent from Appointment.

+ \ No newline at end of file diff --git a/backend/src/appointment/templates/email/rejected.jinja2 b/backend/src/appointment/templates/email/rejected.jinja2 new file mode 100644 index 00000000..7bebd7c6 --- /dev/null +++ b/backend/src/appointment/templates/email/rejected.jinja2 @@ -0,0 +1,10 @@ + + +

+ {{ owner.name|title }} denied your booking request for this time slot: {{ date }}. +

+

+ This message is sent from Thunderbird Appointment. +

+ + diff --git a/backend/src/appointment/tmp/.gitignore b/backend/src/appointment/tmp/.gitignore index a6c57f5f..933daa68 100644 --- a/backend/src/appointment/tmp/.gitignore +++ b/backend/src/appointment/tmp/.gitignore @@ -1 +1 @@ -*.json +*.json diff --git a/backend/test/test_main.py b/backend/test/test_main.py index 4a6976e2..c1e1a693 100644 --- a/backend/test/test_main.py +++ b/backend/test/test_main.py @@ -1,1249 +1,1249 @@ -import json -import time - -from os import getenv as conf -from fastapi.testclient import TestClient -from sqlalchemy import create_engine, insert, select -from sqlalchemy.orm import sessionmaker -from datetime import datetime, timedelta -from http.client import HTTPSConnection -from urllib.parse import quote_plus, urlparse, parse_qs - -from ..src.database import models -from ..src.main import app -from ..src.dependencies.database import get_db -from ..src.database.models import CalendarProvider - -from ..src.controller.calendar import CalDavConnector - -SQLALCHEMY_DATABASE_URL = "sqlite:///test/test.db" - -now = datetime.today() -DAY1 = now.strftime("%Y-%m-%d") -DAY2 = (now + timedelta(days=1)).strftime("%Y-%m-%d") -DAY3 = (now + timedelta(days=2)).strftime("%Y-%m-%d") -DAY5 = (now + timedelta(days=4)).strftime("%Y-%m-%d") -DAY14 = (now + timedelta(days=13)).strftime("%Y-%m-%d") - - -engine = create_engine(SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False}) -TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) - - -models.Base.metadata.drop_all(bind=engine) -models.Base.metadata.create_all(bind=engine) - - -def override_get_db(): - try: - db = TestingSessionLocal() - yield db - finally: - db.close() - - -app.dependency_overrides[get_db] = override_get_db - -client = TestClient(app) - -# handle subscriber authentication -conn = HTTPSConnection(conf("AUTH0_API_DOMAIN")) -payload = "grant_type=password&username=%s&password=%s&audience=%s&scope=%s&client_id=%s&client_secret=%s" % ( - conf("AUTH0_TEST_USER"), - conf("AUTH0_TEST_PASS"), - quote_plus(conf("AUTH0_API_AUDIENCE")), - quote_plus("read:calendars"), - conf("AUTH0_API_CLIENT_ID"), - conf("AUTH0_API_SECRET"), -) -headers = {"content-type": "application/x-www-form-urlencoded"} -conn.request("POST", "/%s/oauth/token" % conf("AUTH0_API_DOMAIN"), payload, headers) - -res = conn.getresponse() -data = res.read() -access_token = json.loads(data.decode("utf-8"))["access_token"] -headers = {"authorization": "Bearer %s" % access_token} - - -""" general tests for configuration and authentication -""" - - -def test_config(): - assert conf("AUTH0_TEST_USER") - assert conf("AUTH0_TEST_PASS") - assert conf("CALDAV_TEST_PRINCIPAL_URL") - assert conf("CALDAV_TEST_CALENDAR_URL") - assert conf("CALDAV_TEST_USER") - assert conf("CALDAV_TEST_PASS") - assert int(conf("TIER_BASIC_CALENDAR_LIMIT")) == 3 - assert int(conf("TIER_PLUS_CALENDAR_LIMIT")) == 5 - assert int(conf("TIER_PRO_CALENDAR_LIMIT")) == 10 - - -def test_health(): - # existing root route - response = client.get("/") - assert response.status_code == 200 - assert response.json() - # undefined route - response = client.get("/abcdefg") - assert response.status_code == 404 - - -def test_access_without_authentication_token(): - response = client.get("/login") - assert response.status_code == 403 - response = client.put("/me") - assert response.status_code == 403 - response = client.get("/me/calendars") - assert response.status_code == 403 - response = client.get("/me/appointments") - assert response.status_code == 403 - response = client.get("/me/signature") - assert response.status_code == 403 - response = client.post("/me/signature") - assert response.status_code == 403 - response = client.post("/cal") - assert response.status_code == 403 - response = client.get("/cal/1") - assert response.status_code == 403 - response = client.put("/cal/1") - assert response.status_code == 403 - response = client.post("/cal/1/connect") - assert response.status_code == 403 - response = client.delete("/cal/1") - assert response.status_code == 403 - response = client.post("/rmt/calendars") - assert response.status_code == 403 - response = client.get("/rmt/cal/1/" + DAY1 + "/" + DAY5) - assert response.status_code == 403 - response = client.post("/apmt") - assert response.status_code == 403 - response = client.get("/apmt/1") - assert response.status_code == 403 - response = client.put("/apmt/1") - assert response.status_code == 403 - response = client.delete("/apmt/1") - assert response.status_code == 403 - response = client.post("/rmt/sync") - assert response.status_code == 403 - response = client.get("/account/download") - assert response.status_code == 403 - response = client.delete("/account/delete") - assert response.status_code == 403 - response = client.get("/google/auth") - assert response.status_code == 403 - - -""" SUBSCRIBERS tests -""" - - -def test_first_login(): - response = client.get("/login", headers=headers) - assert response.status_code == 200, response.text - data = response.json() - assert data["username"] == conf("AUTH0_TEST_USER") - assert data["email"] == conf("AUTH0_TEST_USER") - assert data["name"] == conf("AUTH0_TEST_USER") - assert data["level"] == 1 - assert data["timezone"] is None - - -def test_second_login(): - time.sleep(1) # to not run into Auth0 query limitations - response = client.get("/login", headers=headers) - assert response.status_code == 200, response.text - data = response.json() - assert data["username"] == conf("AUTH0_TEST_USER") - assert data["email"] == conf("AUTH0_TEST_USER") - assert data["name"] == conf("AUTH0_TEST_USER") - assert data["level"] == 1 - assert data["timezone"] is None - - -def test_update_profile_data(): - response = client.put( - "/me", - json={ - "username": "test", - "name": "Test Account", - "timezone": "Europe/Berlin", - }, - headers=headers, - ) - assert response.status_code == 200, response.text - data = response.json() - assert data["username"] == "test" - assert data["name"] == "Test Account" - assert data["timezone"] == "Europe/Berlin" - response = client.get("/login", headers=headers) - data = response.json() - assert data["username"] == "test" - assert data["name"] == "Test Account" - assert data["timezone"] == "Europe/Berlin" - - -def test_signed_short_link(): - response = client.get("/me/signature", headers=headers) - assert response.status_code == 200, response.text - data = response.json() - assert data["url"] - - -def test_signed_short_link_refresh(): - response = client.get("/me/signature", headers=headers) - assert response.status_code == 200, response.text - url_old = response.json()["url"] - response = client.post("/me/signature", headers=headers) - assert response.status_code == 200, response.text - assert response.json() - response = client.get("/me/signature", headers=headers) - assert response.status_code == 200, response.text - url_new = response.json()["url"] - assert url_old != url_new - - -def test_signed_short_link_verification(): - response = client.get("/me/signature", headers=headers) - assert response.status_code == 200, response.text - url = response.json()["url"] - assert url - response = client.post("/verify/signature", json={"url": url}) - assert response.status_code == 200, response.text - assert response.json() - response = client.post("/verify/signature", json={"url": url + "evil"}) - assert response.status_code == 400, response.text - - -""" CALENDARS tests (CalDAV) -""" - - -def test_read_remote_caldav_calendars(): - response = client.post( - "/rmt/calendars", - json={ - "url": conf("CALDAV_TEST_PRINCIPAL_URL"), - "user": conf("CALDAV_TEST_USER"), - "password": conf("CALDAV_TEST_PASS"), - }, - headers=headers, - ) - assert response.status_code == 200, response.text - data = response.json() - assert type(data) is list - assert len(data) > 0 - assert any(c["url"] == conf("CALDAV_TEST_CALENDAR_URL") for c in data) - - -def test_read_connected_calendars_before_creation(): - response = client.get("/me/calendars", headers=headers) - assert response.status_code == 200, response.text - data = response.json() - assert type(data) is list - assert len(data) == 0 - - -def test_read_unconnected_calendars_before_creation(): - response = client.get("/me/calendars", params={"only_connected": False}, headers=headers) - assert response.status_code == 200, response.text - data = response.json() - assert type(data) is list - assert len(data) == 0 - - -def test_create_first_caldav_calendar(): - response = client.post( - "/cal", - json={ - "title": "First CalDAV calendar", - "color": "#123456", - "provider": CalendarProvider.caldav.value, - "url": conf("CALDAV_TEST_CALENDAR_URL"), - "user": conf("CALDAV_TEST_USER"), - "password": conf("CALDAV_TEST_PASS"), - "connected": False, - }, - headers=headers, - ) - assert response.status_code == 200, response.text - data = response.json() - assert data["title"] == "First CalDAV calendar" - assert data["color"] == "#123456" - assert not data["connected"] - assert "url" not in data - assert "user" not in data - assert "password" not in data - - -def test_read_connected_calendars_after_creation(): - response = client.get("/me/calendars", headers=headers) - assert response.status_code == 200, response.text - data = response.json() - assert type(data) is list - assert len(data) == 0 - - -def test_read_unconnected_calendars_after_creation(): - response = client.get("/me/calendars", params={"only_connected": False}, headers=headers) - assert response.status_code == 200, response.text - data = response.json() - assert type(data) is list - assert len(data) == 1 - calendar = data[0] - assert calendar["title"] == "First CalDAV calendar" - assert calendar["color"] == "#123456" - assert not calendar["connected"] - assert "url" not in calendar - assert "user" not in calendar - assert "password" not in calendar - - -def test_read_existing_caldav_calendar(): - response = client.get("/cal/1", headers=headers) - assert response.status_code == 200, response.text - data = response.json() - assert data["title"] == "First CalDAV calendar" - assert data["color"] == "#123456" - assert data["provider"] == CalendarProvider.caldav.value - assert data["url"] == conf("CALDAV_TEST_CALENDAR_URL") - assert data["user"] == conf("CALDAV_TEST_USER") - assert not data["connected"] - assert "password" not in data - - -def test_read_missing_calendar(): - response = client.get("/cal/999", headers=headers) - assert response.status_code == 404, response.text - - -def test_read_foreign_calendar(): - query = insert(models.Calendar).values(owner_id="2", title="a", url="a", user="a", password="a", provider="caldav") - db = TestingSessionLocal() - db.execute(query) - db.commit() - response = client.get("/cal/2", headers=headers) - assert response.status_code == 403, response.text - - -def test_update_existing_caldav_calendar_with_password(): - response = client.put( - "/cal/1", - json={ - "title": "First modified CalDAV calendar", - "color": "#234567", - "url": conf("CALDAV_TEST_CALENDAR_URL") + "x", - "user": conf("CALDAV_TEST_USER") + "x", - "password": conf("CALDAV_TEST_PASS") + "x", - "connected": True, - }, - headers=headers, - ) - assert response.status_code == 200, response.text - data = response.json() - assert data["title"] == "First modified CalDAV calendar" - assert data["color"] == "#234567" - assert not data["connected"] - assert "url" not in data - assert "user" not in data - assert "password" not in data - query = select(models.Calendar).where(models.Calendar.id == 1) - db = TestingSessionLocal() - cal = db.scalars(query).one() - assert cal.url == conf("CALDAV_TEST_CALENDAR_URL") + "x" - assert cal.user == conf("CALDAV_TEST_USER") + "x" - assert cal.password == conf("CALDAV_TEST_PASS") + "x" - - -def test_update_existing_caldav_calendar_without_password(): - response = client.put( - "/cal/1", - json={ - "title": "First modified CalDAV calendar", - "color": "#234567", - "url": conf("CALDAV_TEST_CALENDAR_URL"), - "user": conf("CALDAV_TEST_USER"), - "password": "", - "connected": True, - }, - headers=headers, - ) - assert response.status_code == 200, response.text - data = response.json() - assert data["title"] == "First modified CalDAV calendar" - assert data["color"] == "#234567" - assert "url" not in data - assert "user" not in data - assert "password" not in data - query = select(models.Calendar).where(models.Calendar.id == 1) - db = TestingSessionLocal() - cal = db.scalars(query).one() - assert cal.url == conf("CALDAV_TEST_CALENDAR_URL") - assert cal.user == conf("CALDAV_TEST_USER") - assert cal.password == conf("CALDAV_TEST_PASS") + "x" - - -def test_update_foreign_calendar(): - response = client.put("/cal/2", json={"title": "b", "url": "b", "user": "b", "password": "b"}, headers=headers) - assert response.status_code == 403, response.text - - -def test_connect_caldav_calendar(): - response = client.post("/cal/1/connect", headers=headers) - assert response.status_code == 200, response.text - data = response.json() - assert data["title"] == "First modified CalDAV calendar" - assert data["color"] == "#234567" - assert data["connected"] - assert "url" not in data - assert "user" not in data - assert "password" not in data - - -def test_connect_missing_calendar(): - response = client.post("/cal/999/connect", headers=headers) - assert response.status_code == 404, response.text - - -def test_connect_foreign_calendar(): - response = client.post("/cal/2/connect", headers=headers) - assert response.status_code == 403, response.text - - -def test_read_connected_calendars_after_connection(): - client.post( - "/cal", - json={ - "title": "Second CalDAV calendar", - "color": "#123456", - "provider": CalendarProvider.caldav.value, - "url": "test", - "user": "test", - "password": "test", - }, - headers=headers, - ) - response = client.get("/me/calendars", headers=headers) - assert response.status_code == 200, response.text - data = response.json() - assert type(data) is list - assert len(data) == 1 - calendar = data[0] - assert calendar["title"] == "First modified CalDAV calendar" - assert calendar["color"] == "#234567" - assert calendar["connected"] - assert "url" not in calendar - assert "user" not in calendar - assert "password" not in calendar - - -def test_read_unconnected_calendars_after_connection(): - response = client.get("/me/calendars", params={"only_connected": False}, headers=headers) - assert response.status_code == 200, response.text - data = response.json() - assert type(data) is list - assert len(data) == 2 - - -def test_delete_existing_calendar(): - response = client.delete("/cal/1", headers=headers) - assert response.status_code == 200, response.text - data = response.json() - assert data["title"] == "First modified CalDAV calendar" - assert data["color"] == "#234567" - assert data["connected"] - assert "url" not in data - assert "user" not in data - assert "password" not in data - response = client.get("/cal/1", headers=headers) - assert response.status_code == 404, response.text - response = client.get("/me/calendars", headers=headers) - data = response.json() - assert len(data) == 0 - # add own calendar again for further testing - client.post( - "/cal", - json={ - "title": "First CalDAV calendar", - "color": "#123456", - "provider": CalendarProvider.caldav.value, - "url": conf("CALDAV_TEST_CALENDAR_URL"), - "user": conf("CALDAV_TEST_USER"), - "password": conf("CALDAV_TEST_PASS"), - "connected": True, - }, - headers=headers, - ) - - -def test_delete_missing_calendar(): - response = client.delete("/cal/999", headers=headers) - assert response.status_code == 404, response.text - - -def test_delete_foreign_calendar(): - response = client.delete("/cal/2", headers=headers) - assert response.status_code == 403, response.text - - -def test_connect_more_calendars_than_tier_allows(): - cal = {} - for i in range(1, int(conf("TIER_BASIC_CALENDAR_LIMIT"))): - cal[i] = insert(models.Calendar).values( - owner_id="1", - title="Calendar" + str(i), - color="#123456", - provider="caldav", - url="a", - user="a", - password="a", - connected=True, - ) - db = TestingSessionLocal() - for i in range(1, int(conf("TIER_BASIC_CALENDAR_LIMIT"))): - db.execute(cal[i]) - db.commit() - response = client.post("/cal/3/connect", headers=headers) - assert response.status_code == 403, response.text - # delete test calendar connections for further testing - client.delete("/cal/5", headers=headers) - client.delete("/cal/6", headers=headers) - - -""" CALENDARS tests (Google) -""" - - -def test_google_auth(): - response = client.get("/google/auth?email=" + conf("GOOGLE_TEST_USER"), headers=headers) - assert response.status_code == 200, response.text - url = response.json() - urlobj = urlparse(url) - params = parse_qs(urlobj.query) - assert urlobj.scheme == "https" - assert urlobj.hostname == "accounts.google.com" - assert params["client_id"][0] == conf("GOOGLE_AUTH_CLIENT_ID") - assert params["login_hint"][0] == conf("GOOGLE_TEST_USER") - - -# TODO -# def test_read_remote_google_calendars(): -# response = client.post("/rmt/sync", headers=headers) -# assert response.status_code == 200, response.text -# assert response.json() - - -# TODO -# def test_create_google_calendar(): -# response = client.post( -# "/cal", -# json={ -# "title": "First Google calendar", -# "color": "#123456", -# "provider": CalendarProvider.google.value, -# "connected": False, -# }, -# headers=headers, -# ) -# assert response.status_code == 200, response.text -# data = response.json() -# assert data["title"] == "First Google calendar" -# assert data["color"] == "#123456" -# assert not data["connected"] - - -# TODO -# def test_read_existing_google_calendar(): -# response = client.get("/cal/1", headers=headers) -# assert response.status_code == 200, response.text -# data = response.json() -# assert data["title"] == "First Google calendar" -# assert data["color"] == "#123456" -# assert data["provider"] == CalendarProvider.google.value -# assert data["url"] == conf("Google_TEST_CALENDAR_URL") -# assert data["user"] == conf("Google_TEST_USER") -# assert not data["connected"] -# assert "password" not in data - - -# TODO -# def test_connect_google_calendar(): -# response = client.post("/cal/1/connect", headers=headers) -# assert response.status_code == 200, response.text -# data = response.json() -# assert data["title"] == "First modified Google calendar" -# assert data["color"] == "#234567" -# assert data["connected"] -# assert "url" not in data -# assert "user" not in data -# assert "password" not in data - - -""" APPOINTMENT tests -""" - - -def test_create_appointment_on_connected_calendar(): - response = client.post( - "/apmt", - json={ - "appointment": { - "calendar_id": 4, - "title": "Appointment", - "duration": 180, - "location_type": 2, - "location_name": "Location", - "location_url": "https://test.org", - "location_phone": "+123456789", - "details": "Lorem Ipsum", - "status": 2, - "keep_open": True, - }, - "slots": [ - {"start": DAY1 + " 09:00:00", "duration": 60}, - {"start": DAY2 + " 09:00:00", "duration": 15}, - {"start": DAY3 + " 09:00:00", "duration": 275}, - ], - }, - headers=headers, - ) - assert response.status_code == 200, response.text - data = response.json() - assert data["time_created"] is not None - assert data["time_updated"] is not None - assert data["calendar_id"] == 4 - assert data["title"] == "Appointment" - assert data["duration"] == 180 - assert data["location_type"] == 2 - assert data["location_name"] == "Location" - assert data["location_url"] == "https://test.org" - assert data["location_phone"] == "+123456789" - assert data["details"] == "Lorem Ipsum" - assert data["slug"] is not None, len(data["slug"]) > 8 - assert data["status"] == 2 - assert data["keep_open"] - assert len(data["slots"]) == 3 - assert data["slots"][0]["start"] == DAY1 + "T09:00:00" - assert data["slots"][0]["duration"] == 60 - assert data["slots"][1]["start"] == DAY2 + "T09:00:00" - assert data["slots"][1]["duration"] == 15 - assert data["slots"][2]["start"] == DAY3 + "T09:00:00" - assert data["slots"][2]["duration"] == 275 - - -def test_create_appointment_on_unconnected_calendar(): - response = client.post( - "/apmt", - json={ - "appointment": {"calendar_id": 3, "title": "a", "duration": 30}, - "slots": [{"start": DAY1 + " 09:00:00", "duration": 30}], - }, - headers=headers, - ) - assert response.status_code == 403, response.text - - -def test_create_appointment_on_missing_calendar(): - response = client.post( - "/apmt", - json={ - "appointment": {"calendar_id": "999", "title": "a", "duration": 30}, - "slots": [{"start": DAY1 + " 09:00:00", "duration": 30}], - }, - headers=headers, - ) - assert response.status_code == 404, response.text - - -def test_create_appointment_on_foreign_calendar(): - response = client.post( - "/apmt", - json={ - "appointment": {"calendar_id": "2", "title": "a", "duration": 30}, - "slots": [{"start": DAY1 + " 09:00:00", "duration": 30}], - }, - headers=headers, - ) - assert response.status_code == 403, response.text - - -def test_read_appointments(): - response = client.get("/me/appointments", headers=headers) - assert response.status_code == 200, response.text - data = response.json() - assert type(data) is list - assert len(data) == 1 - data = data[0] - assert data["time_created"] is not None - assert data["time_updated"] is not None - assert data["calendar_id"] == 4 - assert data["title"] == "Appointment" - assert data["duration"] == 180 - assert data["location_type"] == 2 - assert data["location_name"] == "Location" - assert data["location_url"] == "https://test.org" - assert data["location_phone"] == "+123456789" - assert data["details"] == "Lorem Ipsum" - assert data["slug"] is not None, len(data["slug"]) > 8 - assert data["status"] == 2 - assert data["keep_open"] - assert len(data["slots"]) == 3 - assert data["slots"][0]["start"] == DAY1 + "T09:00:00" - assert data["slots"][0]["duration"] == 60 - assert data["slots"][1]["start"] == DAY2 + "T09:00:00" - assert data["slots"][1]["duration"] == 15 - assert data["slots"][2]["start"] == DAY3 + "T09:00:00" - assert data["slots"][2]["duration"] == 275 - - -def test_read_existing_appointment(): - response = client.get("/apmt/1", headers=headers) - assert response.status_code == 200, response.text - data = response.json() - assert data["time_created"] is not None - assert data["time_updated"] is not None - assert data["calendar_id"] == 4 - assert data["title"] == "Appointment" - assert data["duration"] == 180 - assert data["location_type"] == 2 - assert data["location_name"] == "Location" - assert data["location_url"] == "https://test.org" - assert data["location_phone"] == "+123456789" - assert data["details"] == "Lorem Ipsum" - assert data["slug"] is not None, len(data["slug"]) > 8 - assert data["status"] == 2 - assert data["keep_open"] - assert len(data["slots"]) == 3 - assert data["slots"][0]["start"] == DAY1 + "T09:00:00" - assert data["slots"][0]["duration"] == 60 - assert data["slots"][1]["start"] == DAY2 + "T09:00:00" - assert data["slots"][1]["duration"] == 15 - assert data["slots"][2]["start"] == DAY3 + "T09:00:00" - assert data["slots"][2]["duration"] == 275 - - -def test_read_missing_appointment(): - response = client.get("/apmt/999", headers=headers) - assert response.status_code == 404, response.text - - -def test_read_foreign_appointment(): - stmt = insert(models.Appointment).values(calendar_id="2", duration="60", title="abc", slug="test") - db = TestingSessionLocal() - db.execute(stmt) - db.commit() - response = client.get("/apmt/2", headers=headers) - assert response.status_code == 403, response.text - - -def test_update_existing_appointment(): - response = client.put( - "/apmt/1", - json={ - "appointment": { - "calendar_id": 4, - "title": "Appointmentx", - "duration": 90, - "location_type": 1, - "location_name": "Locationx", - "location_url": "https://testx.org", - "location_phone": "+1234567890", - "details": "Lorem Ipsumx", - "status": 1, - "keep_open": False, - }, - "slots": [ - {"start": DAY1 + " 11:00:00", "duration": 30}, - {"start": DAY2 + " 11:00:00", "duration": 30}, - {"start": DAY3 + " 11:00:00", "duration": 30}, - ], - }, - headers=headers, - ) - assert response.status_code == 200, response.text - data = response.json() - assert data["time_created"] is not None - assert data["time_updated"] is not None - assert data["calendar_id"] == 4 - assert data["title"] == "Appointmentx" - assert data["duration"] == 90 - assert data["location_type"] == 1 - assert data["location_name"] == "Locationx" - assert data["location_url"] == "https://testx.org" - assert data["location_phone"] == "+1234567890" - assert data["details"] == "Lorem Ipsumx" - assert data["slug"] is not None, len(data["slug"]) > 8 - assert data["status"] == 1 - assert not data["keep_open"] - assert len(data["slots"]) == 3 - assert data["slots"][0]["start"] == DAY1 + "T11:00:00" - assert data["slots"][0]["duration"] == 30 - assert data["slots"][1]["start"] == DAY2 + "T11:00:00" - assert data["slots"][1]["duration"] == 30 - assert data["slots"][2]["start"] == DAY3 + "T11:00:00" - assert data["slots"][2]["duration"] == 30 - - -def test_update_missing_appointment(): - response = client.put( - "/apmt/999", - json={ - "appointment": {"calendar_id": "2", "title": "a", "duration": 30}, - "slots": [{"start": DAY1 + " 09:00:00", "duration": 30}], - }, - headers=headers, - ) - assert response.status_code == 404, response.text - - -def test_update_foreign_appointment(): - response = client.put( - "/apmt/2", - json={ - "appointment": {"calendar_id": "2", "title": "a", "duration": 30}, - "slots": [{"start": DAY1 + " 09:00:00", "duration": 30}], - }, - headers=headers, - ) - assert response.status_code == 403, response.text - - -def test_delete_existing_appointment(): - response = client.delete("/apmt/1", headers=headers) - assert response.status_code == 200, response.text - data = response.json() - assert data["time_created"] is not None - assert data["time_updated"] is not None - assert data["calendar_id"] == 4 - assert data["title"] == "Appointmentx" - assert data["duration"] == 90 - assert data["location_type"] == 1 - assert data["location_name"] == "Locationx" - assert data["location_url"] == "https://testx.org" - assert data["location_phone"] == "+1234567890" - assert data["details"] == "Lorem Ipsumx" - assert data["slug"] is not None, len(data["slug"]) > 8 - assert data["status"] == 1 - assert not data["keep_open"] - assert len(data["slots"]) == 3 - assert data["slots"][0]["start"] == DAY1 + "T11:00:00" - assert data["slots"][0]["duration"] == 30 - assert data["slots"][1]["start"] == DAY2 + "T11:00:00" - assert data["slots"][1]["duration"] == 30 - assert data["slots"][2]["start"] == DAY3 + "T11:00:00" - assert data["slots"][2]["duration"] == 30 - response = client.get("/apmt/1", headers=headers) - assert response.status_code == 404, response.text - response = client.get("/me/appointments", headers=headers) - assert response.status_code == 200, response.text - data = response.json() - assert type(data) is list - assert len(data) == 0 - # add appointment again for further testing - client.post( - "/apmt", - json={ - "appointment": { - "calendar_id": 4, - "title": "Appointment", - "duration": 180, - "location_type": 2, - "location_name": "Location", - "location_url": "https://test.org", - "location_phone": "+123456789", - "details": "Lorem Ipsum", - "status": 2, - "keep_open": True, - "slug": "abcdef", - }, - "slots": [ - {"start": DAY1 + " 09:00:00", "duration": 60}, - {"start": DAY2 + " 09:00:00", "duration": 15}, - {"start": DAY3 + " 09:00:00", "duration": 275}, - ], - }, - headers=headers, - ) - - -def test_delete_missing_appointment(): - response = client.delete("/apmt/999", headers=headers) - assert response.status_code == 404, response.text - - -def test_delete_foreign_appointment(): - response = client.delete("/apmt/2", headers=headers) - assert response.status_code == 403, response.text - - -def test_read_public_existing_appointment(): - response = client.get("/apmt/public/abcdef") - assert response.status_code == 200, response.text - data = response.json() - assert "calendar_id" not in data - assert "status" not in data - assert data["title"] == "Appointment" - assert data["details"] == "Lorem Ipsum" - assert data["slug"] == "abcdef" - assert data["owner_name"] == "Test Account" - assert len(data["slots"]) == 3 - assert data["slots"][0]["start"] == DAY1 + "T09:00:00" - assert data["slots"][0]["duration"] == 60 - assert data["slots"][1]["start"] == DAY2 + "T09:00:00" - assert data["slots"][1]["duration"] == 15 - assert data["slots"][2]["start"] == DAY3 + "T09:00:00" - assert data["slots"][2]["duration"] == 275 - - -def test_read_public_missing_appointment(): - response = client.get("/apmt/public/missing") - assert response.status_code == 404, response.text - - -def test_attendee_selects_appointment_slot(): - response = client.put( - "/apmt/public/abcdef", - json={ - "slot_id": 1, - "attendee": { - "email": "person@test.org", - "name": "John Doe", - }, - }, - ) - assert response.status_code == 200, response.text - data = response.json() - assert data["slot_id"] == 1 - assert data["attendee"]["email"] == "person@test.org" - assert data["attendee"]["name"] == "John Doe" - - -def test_read_public_appointment_after_attendee_selection(): - response = client.get("/apmt/public/abcdef") - assert response.status_code == 200, response.text - data = response.json() - assert len(data["slots"]) == 3 - assert data["slots"][0]["attendee_id"] == 1 - - -def test_attendee_selects_slot_of_unavailable_appointment(): - response = client.put( - "/apmt/public/abcdef", - json={"slot_id": 1, "attendee": {"email": "a", "name": "b"}}, - ) - assert response.status_code == 403, response.text - - -def test_attendee_selects_slot_of_missing_appointment(): - response = client.put( - "/apmt/public/missing", - json={"slot_id": 1, "attendee": {"email": "a", "name": "b"}}, - ) - assert response.status_code == 404, response.text - - -def test_attendee_selects_missing_slot_of_existing_appointment(): - response = client.put( - "/apmt/public/abcdef", - json={"slot_id": 999, "attendee": {"email": "a", "name": "b"}}, - ) - assert response.status_code == 404, response.text - - -def test_attendee_provides_invalid_email_address(): - response = client.put( - "/apmt/public/abcdef", - json={"slot_id": 2, "attendee": {"email": "a", "name": "b"}}, - ) - assert response.status_code == 400, response.text - - -def test_get_remote_caldav_events(): - response = client.get("/rmt/cal/4/" + DAY1 + "/" + DAY3, headers=headers) - assert response.status_code == 200, response.text - data = response.json() - assert len(data) == 1 - assert data[0]["title"] == "Appointment" - assert data[0]["start"][:19] == DAY1 + " 09:00:00" - assert data[0]["end"][:19] == DAY1 + " 10:00:00" - # delete event again to prevent calendar pollution - con = CalDavConnector(conf("CALDAV_TEST_CALENDAR_URL"), conf("CALDAV_TEST_USER"), conf("CALDAV_TEST_PASS")) - n = con.delete_events(start=DAY1) - assert n == 1 - - -""" SCHEDULE tests -""" - - -def test_create_schedule_on_connected_calendar(): - response = client.post( - "/schedule", - json={ - "calendar_id": 4, - "name": "Schedule", - "location_type": 2, - "location_url": "https://test.org", - "details": "Lorem Ipsum", - "start_date": DAY1, - "end_date": DAY14, - "start_time": "10:00", - "end_time": "18:00", - "earliest_booking": 1440, - "farthest_booking": 20160, - "weekdays": [1, 2, 3, 4, 5], - "slot_duration": 30, - }, - headers=headers, - ) - assert response.status_code == 200, response.text - data = response.json() - assert data["time_created"] is not None - assert data["time_updated"] is not None - assert data["calendar_id"] == 4 - assert data["name"] == "Schedule" - assert data["location_type"] == 2 - assert data["location_url"] == "https://test.org" - assert data["details"] == "Lorem Ipsum" - assert data["start_date"] == DAY1 - assert data["end_date"] == DAY14 - assert data["start_time"] == "10:00" - assert data["end_time"] == "18:00" - assert data["earliest_booking"] == 1440 - assert data["farthest_booking"] == 20160 - assert data["weekdays"] is not None - weekdays = data["weekdays"] - assert len(weekdays) == 5 - assert weekdays == [1, 2, 3, 4, 5] - assert data["slot_duration"] == 30 - - -def test_create_schedule_on_unconnected_calendar(): - response = client.post( - "/schedule", - json={"calendar_id": 3, "name": "Schedule"}, - headers=headers, - ) - assert response.status_code == 403, response.text - - -def test_create_schedule_on_missing_calendar(): - response = client.post( - "/schedule", - json={"calendar_id": 999, "name": "Schedule"}, - headers=headers, - ) - assert response.status_code == 404, response.text - - -def test_create_schedule_on_foreign_calendar(): - response = client.post( - "/schedule", - json={"calendar_id": 2, "name": "Schedule"}, - headers=headers, - ) - assert response.status_code == 403, response.text - - -def test_read_schedules(): - response = client.get("/schedule", headers=headers) - assert response.status_code == 200, response.text - data = response.json() - assert type(data) is list - assert len(data) == 1 - data = data[0] - assert data["time_created"] is not None - assert data["time_updated"] is not None - assert data["calendar_id"] == 4 - assert data["name"] == "Schedule" - assert data["location_type"] == 2 - assert data["location_url"] == "https://test.org" - assert data["details"] == "Lorem Ipsum" - assert data["start_date"] == DAY1 - assert data["end_date"] == DAY14 - assert data["start_time"] == "10:00" - assert data["end_time"] == "18:00" - assert data["earliest_booking"] == 1440 - assert data["farthest_booking"] == 20160 - assert data["weekdays"] is not None - weekdays = data["weekdays"] - assert len(weekdays) == 5 - assert weekdays == [1, 2, 3, 4, 5] - assert data["slot_duration"] == 30 - - -def test_read_existing_schedule(): - response = client.get("/schedule/1", headers=headers) - assert response.status_code == 200, response.text - data = response.json() - assert data["time_created"] is not None - assert data["time_updated"] is not None - assert data["calendar_id"] == 4 - assert data["name"] == "Schedule" - assert data["location_type"] == 2 - assert data["location_url"] == "https://test.org" - assert data["details"] == "Lorem Ipsum" - assert data["start_date"] == DAY1 - assert data["end_date"] == DAY14 - assert data["start_time"] == "10:00" - assert data["end_time"] == "18:00" - assert data["earliest_booking"] == 1440 - assert data["farthest_booking"] == 20160 - assert data["weekdays"] is not None - weekdays = data["weekdays"] - assert len(weekdays) == 5 - assert weekdays == [1, 2, 3, 4, 5] - assert data["slot_duration"] == 30 - - -def test_read_missing_schedule(): - response = client.get("/schedule/999", headers=headers) - assert response.status_code == 404, response.text - - -def test_read_foreign_schedule(): - stmt = insert(models.Schedule).values(calendar_id=2, name="abc") - db = TestingSessionLocal() - db.execute(stmt) - db.commit() - response = client.get("/schedule/2", headers=headers) - assert response.status_code == 403, response.text - - -def test_update_existing_schedule(): - response = client.put( - "/schedule/1", - json={ - "calendar_id": 4, - "name": "Schedulex", - "location_type": 1, - "location_url": "https://testx.org", - "details": "Lorem Ipsumx", - "start_date": DAY2, - "end_date": DAY5, - "start_time": "09:00", - "end_time": "17:00", - "earliest_booking": 1000, - "farthest_booking": 20000, - "weekdays": [2, 4, 6], - "slot_duration": 60, - }, - headers=headers, - ) - assert response.status_code == 200, response.text - data = response.json() - assert data["time_created"] is not None - assert data["time_updated"] is not None - assert data["calendar_id"] == 4 - assert data["name"] == "Schedulex" - assert data["location_type"] == 1 - assert data["location_url"] == "https://testx.org" - assert data["details"] == "Lorem Ipsumx" - assert data["start_date"] == DAY2 - assert data["end_date"] == DAY5 - assert data["start_time"] == "09:00" - assert data["end_time"] == "17:00" - assert data["earliest_booking"] == 1000 - assert data["farthest_booking"] == 20000 - assert data["weekdays"] is not None - weekdays = data["weekdays"] - assert len(weekdays) == 3 - assert weekdays == [2, 4, 6] - assert data["slot_duration"] == 60 - - -def test_update_missing_schedule(): - response = client.put( - "/schedule/999", - json={"calendar_id": 1, "name": "Schedule"}, - headers=headers, - ) - assert response.status_code == 404, response.text - - -def test_update_foreign_schedule(): - response = client.put( - "/schedule/2", - json={"calendar_id": 2, "name": "Schedule"}, - headers=headers, - ) - assert response.status_code == 403, response.text - - -def test_read_schedule_availabilities(): - response = client.get("/me/signature", headers=headers) - url = response.json()["url"] - response = client.post("/schedule/public/availability", json={"url": url}) - assert response.status_code == 200, response.text - data = response.json() - assert data["title"] == "Schedulex" - assert data["details"] == "Lorem Ipsumx" - assert data["owner_name"] == "Test Account" - assert len(data["slots"]) > 5 - # TODO: some more assertions are needed here to check for correct slot generation, like: - # no slots on unchecked weekdays - # no slots before start or earliest start - # no slots after end or farthest end - # no slots during existing events - - -def test_read_schedule_availabilities_from_invalid_link(): - response = client.post("/schedule/public/availability", json={"url": "https://evil.corp"}) - assert response.status_code == 401, response.text - - -# TODO -# def test_read_schedule_availabilities_with_no_connected_calendars(): -# response = client.get("/me/signature", headers=headers) -# url = response.json()["url"] -# response = client.post("/schedule/public/availability", json={"url": url}) -# assert response.status_code == 404, response.text - - -# TODO -# def test_read_schedule_availabilities_with_no_existing_schedule(): -# response = client.get("/me/signature", headers=headers) -# url = response.json()["url"] -# response = client.post("/schedule/public/availability", json={"url": url}) -# assert response.status_code == 404, response.text - - -# TODO -# def test_read_schedule_availabilities_on_disabled_schedule(): -# response = client.get("/me/signature", headers=headers) -# url = response.json()["url"] -# response = client.post("/schedule/public/availability", json={"url": url}) -# assert response.status_code == 404, response.text - - -# TODO -# def test_read_schedule_availabilities_with_no_actual_booking_slots(): -# response = client.get("/me/signature", headers=headers) -# url = response.json()["url"] -# response = client.post("/schedule/public/availability", json={"url": url}) -# assert response.status_code == 404, response.text - - -""" MISCELLANEOUS tests -""" - - -def test_get_invitation_ics_file(): - response = client.get("/apmt/serve/ics/abcdef/1") - assert response.status_code == 200, response.text - data = response.json() - assert data["name"] == "invite" - assert data["content_type"] == "text/calendar" - assert "data" in data - - -def test_get_invitation_ics_file_for_missing_appointment(): - response = client.get("/apmt/serve/ics/missing/1") - assert response.status_code == 404, response.text - - -def test_get_invitation_ics_file_for_missing_slot(): - response = client.get("/apmt/serve/ics/abcdef/999") - assert response.status_code == 404, response.text +import json +import time + +from os import getenv as conf +from fastapi.testclient import TestClient +from sqlalchemy import create_engine, insert, select +from sqlalchemy.orm import sessionmaker +from datetime import datetime, timedelta +from http.client import HTTPSConnection +from urllib.parse import quote_plus, urlparse, parse_qs + +from ..src.database import models +from ..src.main import app +from ..src.dependencies.database import get_db +from ..src.database.models import CalendarProvider + +from ..src.controller.calendar import CalDavConnector, DATEFMT + +SQLALCHEMY_DATABASE_URL = "sqlite:///test/test.db" + +now = datetime.today() +DAY1 = now.strftime(DATEFMT) +DAY2 = (now + timedelta(days=1)).strftime(DATEFMT) +DAY3 = (now + timedelta(days=2)).strftime(DATEFMT) +DAY5 = (now + timedelta(days=4)).strftime(DATEFMT) +DAY14 = (now + timedelta(days=13)).strftime(DATEFMT) + + +engine = create_engine(SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False}) +TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + + +models.Base.metadata.drop_all(bind=engine) +models.Base.metadata.create_all(bind=engine) + + +def override_get_db(): + try: + db = TestingSessionLocal() + yield db + finally: + db.close() + + +app.dependency_overrides[get_db] = override_get_db + +client = TestClient(app) + +# handle subscriber authentication +conn = HTTPSConnection(conf("AUTH0_API_DOMAIN")) +payload = "grant_type=password&username=%s&password=%s&audience=%s&scope=%s&client_id=%s&client_secret=%s" % ( + conf("AUTH0_TEST_USER"), + conf("AUTH0_TEST_PASS"), + quote_plus(conf("AUTH0_API_AUDIENCE")), + quote_plus("read:calendars"), + conf("AUTH0_API_CLIENT_ID"), + conf("AUTH0_API_SECRET"), +) +headers = {"content-type": "application/x-www-form-urlencoded"} +conn.request("POST", "/%s/oauth/token" % conf("AUTH0_API_DOMAIN"), payload, headers) + +res = conn.getresponse() +data = res.read() +access_token = json.loads(data.decode("utf-8"))["access_token"] +headers = {"authorization": "Bearer %s" % access_token} + + +""" general tests for configuration and authentication +""" + + +def test_config(): + assert conf("AUTH0_TEST_USER") + assert conf("AUTH0_TEST_PASS") + assert conf("CALDAV_TEST_PRINCIPAL_URL") + assert conf("CALDAV_TEST_CALENDAR_URL") + assert conf("CALDAV_TEST_USER") + assert conf("CALDAV_TEST_PASS") + assert int(conf("TIER_BASIC_CALENDAR_LIMIT")) == 3 + assert int(conf("TIER_PLUS_CALENDAR_LIMIT")) == 5 + assert int(conf("TIER_PRO_CALENDAR_LIMIT")) == 10 + + +def test_health(): + # existing root route + response = client.get("/") + assert response.status_code == 200 + assert response.json() + # undefined route + response = client.get("/abcdefg") + assert response.status_code == 404 + + +def test_access_without_authentication_token(): + response = client.get("/login") + assert response.status_code == 403 + response = client.put("/me") + assert response.status_code == 403 + response = client.get("/me/calendars") + assert response.status_code == 403 + response = client.get("/me/appointments") + assert response.status_code == 403 + response = client.get("/me/signature") + assert response.status_code == 403 + response = client.post("/me/signature") + assert response.status_code == 403 + response = client.post("/cal") + assert response.status_code == 403 + response = client.get("/cal/1") + assert response.status_code == 403 + response = client.put("/cal/1") + assert response.status_code == 403 + response = client.post("/cal/1/connect") + assert response.status_code == 403 + response = client.delete("/cal/1") + assert response.status_code == 403 + response = client.post("/rmt/calendars") + assert response.status_code == 403 + response = client.get("/rmt/cal/1/" + DAY1 + "/" + DAY5) + assert response.status_code == 403 + response = client.post("/apmt") + assert response.status_code == 403 + response = client.get("/apmt/1") + assert response.status_code == 403 + response = client.put("/apmt/1") + assert response.status_code == 403 + response = client.delete("/apmt/1") + assert response.status_code == 403 + response = client.post("/rmt/sync") + assert response.status_code == 403 + response = client.get("/account/download") + assert response.status_code == 403 + response = client.delete("/account/delete") + assert response.status_code == 403 + response = client.get("/google/auth") + assert response.status_code == 403 + + +""" SUBSCRIBERS tests +""" + + +def test_first_login(): + response = client.get("/login", headers=headers) + assert response.status_code == 200, response.text + data = response.json() + assert data["username"] == conf("AUTH0_TEST_USER") + assert data["email"] == conf("AUTH0_TEST_USER") + assert data["name"] == conf("AUTH0_TEST_USER") + assert data["level"] == 1 + assert data["timezone"] is None + + +def test_second_login(): + time.sleep(1) # to not run into Auth0 query limitations + response = client.get("/login", headers=headers) + assert response.status_code == 200, response.text + data = response.json() + assert data["username"] == conf("AUTH0_TEST_USER") + assert data["email"] == conf("AUTH0_TEST_USER") + assert data["name"] == conf("AUTH0_TEST_USER") + assert data["level"] == 1 + assert data["timezone"] is None + + +def test_update_profile_data(): + response = client.put( + "/me", + json={ + "username": "test", + "name": "Test Account", + "timezone": "Europe/Berlin", + }, + headers=headers, + ) + assert response.status_code == 200, response.text + data = response.json() + assert data["username"] == "test" + assert data["name"] == "Test Account" + assert data["timezone"] == "Europe/Berlin" + response = client.get("/login", headers=headers) + data = response.json() + assert data["username"] == "test" + assert data["name"] == "Test Account" + assert data["timezone"] == "Europe/Berlin" + + +def test_signed_short_link(): + response = client.get("/me/signature", headers=headers) + assert response.status_code == 200, response.text + data = response.json() + assert data["url"] + + +def test_signed_short_link_refresh(): + response = client.get("/me/signature", headers=headers) + assert response.status_code == 200, response.text + url_old = response.json()["url"] + response = client.post("/me/signature", headers=headers) + assert response.status_code == 200, response.text + assert response.json() + response = client.get("/me/signature", headers=headers) + assert response.status_code == 200, response.text + url_new = response.json()["url"] + assert url_old != url_new + + +def test_signed_short_link_verification(): + response = client.get("/me/signature", headers=headers) + assert response.status_code == 200, response.text + url = response.json()["url"] + assert url + response = client.post("/verify/signature", json={"url": url}) + assert response.status_code == 200, response.text + assert response.json() + response = client.post("/verify/signature", json={"url": url + "evil"}) + assert response.status_code == 400, response.text + + +""" CALENDARS tests (CalDAV) +""" + + +def test_read_remote_caldav_calendars(): + response = client.post( + "/rmt/calendars", + json={ + "url": conf("CALDAV_TEST_PRINCIPAL_URL"), + "user": conf("CALDAV_TEST_USER"), + "password": conf("CALDAV_TEST_PASS"), + }, + headers=headers, + ) + assert response.status_code == 200, response.text + data = response.json() + assert type(data) is list + assert len(data) > 0 + assert any(c["url"] == conf("CALDAV_TEST_CALENDAR_URL") for c in data) + + +def test_read_connected_calendars_before_creation(): + response = client.get("/me/calendars", headers=headers) + assert response.status_code == 200, response.text + data = response.json() + assert type(data) is list + assert len(data) == 0 + + +def test_read_unconnected_calendars_before_creation(): + response = client.get("/me/calendars", params={"only_connected": False}, headers=headers) + assert response.status_code == 200, response.text + data = response.json() + assert type(data) is list + assert len(data) == 0 + + +def test_create_first_caldav_calendar(): + response = client.post( + "/cal", + json={ + "title": "First CalDAV calendar", + "color": "#123456", + "provider": CalendarProvider.caldav.value, + "url": conf("CALDAV_TEST_CALENDAR_URL"), + "user": conf("CALDAV_TEST_USER"), + "password": conf("CALDAV_TEST_PASS"), + "connected": False, + }, + headers=headers, + ) + assert response.status_code == 200, response.text + data = response.json() + assert data["title"] == "First CalDAV calendar" + assert data["color"] == "#123456" + assert not data["connected"] + assert "url" not in data + assert "user" not in data + assert "password" not in data + + +def test_read_connected_calendars_after_creation(): + response = client.get("/me/calendars", headers=headers) + assert response.status_code == 200, response.text + data = response.json() + assert type(data) is list + assert len(data) == 0 + + +def test_read_unconnected_calendars_after_creation(): + response = client.get("/me/calendars", params={"only_connected": False}, headers=headers) + assert response.status_code == 200, response.text + data = response.json() + assert type(data) is list + assert len(data) == 1 + calendar = data[0] + assert calendar["title"] == "First CalDAV calendar" + assert calendar["color"] == "#123456" + assert not calendar["connected"] + assert "url" not in calendar + assert "user" not in calendar + assert "password" not in calendar + + +def test_read_existing_caldav_calendar(): + response = client.get("/cal/1", headers=headers) + assert response.status_code == 200, response.text + data = response.json() + assert data["title"] == "First CalDAV calendar" + assert data["color"] == "#123456" + assert data["provider"] == CalendarProvider.caldav.value + assert data["url"] == conf("CALDAV_TEST_CALENDAR_URL") + assert data["user"] == conf("CALDAV_TEST_USER") + assert not data["connected"] + assert "password" not in data + + +def test_read_missing_calendar(): + response = client.get("/cal/999", headers=headers) + assert response.status_code == 404, response.text + + +def test_read_foreign_calendar(): + query = insert(models.Calendar).values(owner_id="2", title="a", url="a", user="a", password="a", provider="caldav") + db = TestingSessionLocal() + db.execute(query) + db.commit() + response = client.get("/cal/2", headers=headers) + assert response.status_code == 403, response.text + + +def test_update_existing_caldav_calendar_with_password(): + response = client.put( + "/cal/1", + json={ + "title": "First modified CalDAV calendar", + "color": "#234567", + "url": conf("CALDAV_TEST_CALENDAR_URL") + "x", + "user": conf("CALDAV_TEST_USER") + "x", + "password": conf("CALDAV_TEST_PASS") + "x", + "connected": True, + }, + headers=headers, + ) + assert response.status_code == 200, response.text + data = response.json() + assert data["title"] == "First modified CalDAV calendar" + assert data["color"] == "#234567" + assert not data["connected"] + assert "url" not in data + assert "user" not in data + assert "password" not in data + query = select(models.Calendar).where(models.Calendar.id == 1) + db = TestingSessionLocal() + cal = db.scalars(query).one() + assert cal.url == conf("CALDAV_TEST_CALENDAR_URL") + "x" + assert cal.user == conf("CALDAV_TEST_USER") + "x" + assert cal.password == conf("CALDAV_TEST_PASS") + "x" + + +def test_update_existing_caldav_calendar_without_password(): + response = client.put( + "/cal/1", + json={ + "title": "First modified CalDAV calendar", + "color": "#234567", + "url": conf("CALDAV_TEST_CALENDAR_URL"), + "user": conf("CALDAV_TEST_USER"), + "password": "", + "connected": True, + }, + headers=headers, + ) + assert response.status_code == 200, response.text + data = response.json() + assert data["title"] == "First modified CalDAV calendar" + assert data["color"] == "#234567" + assert "url" not in data + assert "user" not in data + assert "password" not in data + query = select(models.Calendar).where(models.Calendar.id == 1) + db = TestingSessionLocal() + cal = db.scalars(query).one() + assert cal.url == conf("CALDAV_TEST_CALENDAR_URL") + assert cal.user == conf("CALDAV_TEST_USER") + assert cal.password == conf("CALDAV_TEST_PASS") + "x" + + +def test_update_foreign_calendar(): + response = client.put("/cal/2", json={"title": "b", "url": "b", "user": "b", "password": "b"}, headers=headers) + assert response.status_code == 403, response.text + + +def test_connect_caldav_calendar(): + response = client.post("/cal/1/connect", headers=headers) + assert response.status_code == 200, response.text + data = response.json() + assert data["title"] == "First modified CalDAV calendar" + assert data["color"] == "#234567" + assert data["connected"] + assert "url" not in data + assert "user" not in data + assert "password" not in data + + +def test_connect_missing_calendar(): + response = client.post("/cal/999/connect", headers=headers) + assert response.status_code == 404, response.text + + +def test_connect_foreign_calendar(): + response = client.post("/cal/2/connect", headers=headers) + assert response.status_code == 403, response.text + + +def test_read_connected_calendars_after_connection(): + client.post( + "/cal", + json={ + "title": "Second CalDAV calendar", + "color": "#123456", + "provider": CalendarProvider.caldav.value, + "url": "test", + "user": "test", + "password": "test", + }, + headers=headers, + ) + response = client.get("/me/calendars", headers=headers) + assert response.status_code == 200, response.text + data = response.json() + assert type(data) is list + assert len(data) == 1 + calendar = data[0] + assert calendar["title"] == "First modified CalDAV calendar" + assert calendar["color"] == "#234567" + assert calendar["connected"] + assert "url" not in calendar + assert "user" not in calendar + assert "password" not in calendar + + +def test_read_unconnected_calendars_after_connection(): + response = client.get("/me/calendars", params={"only_connected": False}, headers=headers) + assert response.status_code == 200, response.text + data = response.json() + assert type(data) is list + assert len(data) == 2 + + +def test_delete_existing_calendar(): + response = client.delete("/cal/1", headers=headers) + assert response.status_code == 200, response.text + data = response.json() + assert data["title"] == "First modified CalDAV calendar" + assert data["color"] == "#234567" + assert data["connected"] + assert "url" not in data + assert "user" not in data + assert "password" not in data + response = client.get("/cal/1", headers=headers) + assert response.status_code == 404, response.text + response = client.get("/me/calendars", headers=headers) + data = response.json() + assert len(data) == 0 + # add own calendar again for further testing + client.post( + "/cal", + json={ + "title": "First CalDAV calendar", + "color": "#123456", + "provider": CalendarProvider.caldav.value, + "url": conf("CALDAV_TEST_CALENDAR_URL"), + "user": conf("CALDAV_TEST_USER"), + "password": conf("CALDAV_TEST_PASS"), + "connected": True, + }, + headers=headers, + ) + + +def test_delete_missing_calendar(): + response = client.delete("/cal/999", headers=headers) + assert response.status_code == 404, response.text + + +def test_delete_foreign_calendar(): + response = client.delete("/cal/2", headers=headers) + assert response.status_code == 403, response.text + + +def test_connect_more_calendars_than_tier_allows(): + cal = {} + for i in range(1, int(conf("TIER_BASIC_CALENDAR_LIMIT"))): + cal[i] = insert(models.Calendar).values( + owner_id="1", + title="Calendar" + str(i), + color="#123456", + provider="caldav", + url="a", + user="a", + password="a", + connected=True, + ) + db = TestingSessionLocal() + for i in range(1, int(conf("TIER_BASIC_CALENDAR_LIMIT"))): + db.execute(cal[i]) + db.commit() + response = client.post("/cal/3/connect", headers=headers) + assert response.status_code == 403, response.text + # delete test calendar connections for further testing + client.delete("/cal/5", headers=headers) + client.delete("/cal/6", headers=headers) + + +""" CALENDARS tests (Google) +""" + + +def test_google_auth(): + response = client.get("/google/auth?email=" + conf("GOOGLE_TEST_USER"), headers=headers) + assert response.status_code == 200, response.text + url = response.json() + urlobj = urlparse(url) + params = parse_qs(urlobj.query) + assert urlobj.scheme == "https" + assert urlobj.hostname == "accounts.google.com" + assert params["client_id"][0] == conf("GOOGLE_AUTH_CLIENT_ID") + assert params["login_hint"][0] == conf("GOOGLE_TEST_USER") + + +# TODO +# def test_read_remote_google_calendars(): +# response = client.post("/rmt/sync", headers=headers) +# assert response.status_code == 200, response.text +# assert response.json() + + +# TODO +# def test_create_google_calendar(): +# response = client.post( +# "/cal", +# json={ +# "title": "First Google calendar", +# "color": "#123456", +# "provider": CalendarProvider.google.value, +# "connected": False, +# }, +# headers=headers, +# ) +# assert response.status_code == 200, response.text +# data = response.json() +# assert data["title"] == "First Google calendar" +# assert data["color"] == "#123456" +# assert not data["connected"] + + +# TODO +# def test_read_existing_google_calendar(): +# response = client.get("/cal/1", headers=headers) +# assert response.status_code == 200, response.text +# data = response.json() +# assert data["title"] == "First Google calendar" +# assert data["color"] == "#123456" +# assert data["provider"] == CalendarProvider.google.value +# assert data["url"] == conf("Google_TEST_CALENDAR_URL") +# assert data["user"] == conf("Google_TEST_USER") +# assert not data["connected"] +# assert "password" not in data + + +# TODO +# def test_connect_google_calendar(): +# response = client.post("/cal/1/connect", headers=headers) +# assert response.status_code == 200, response.text +# data = response.json() +# assert data["title"] == "First modified Google calendar" +# assert data["color"] == "#234567" +# assert data["connected"] +# assert "url" not in data +# assert "user" not in data +# assert "password" not in data + + +""" APPOINTMENT tests +""" + + +def test_create_appointment_on_connected_calendar(): + response = client.post( + "/apmt", + json={ + "appointment": { + "calendar_id": 4, + "title": "Appointment", + "duration": 180, + "location_type": 2, + "location_name": "Location", + "location_url": "https://test.org", + "location_phone": "+123456789", + "details": "Lorem Ipsum", + "status": 2, + "keep_open": True, + }, + "slots": [ + {"start": DAY1 + " 09:00:00", "duration": 60}, + {"start": DAY2 + " 09:00:00", "duration": 15}, + {"start": DAY3 + " 09:00:00", "duration": 275}, + ], + }, + headers=headers, + ) + assert response.status_code == 200, response.text + data = response.json() + assert data["time_created"] is not None + assert data["time_updated"] is not None + assert data["calendar_id"] == 4 + assert data["title"] == "Appointment" + assert data["duration"] == 180 + assert data["location_type"] == 2 + assert data["location_name"] == "Location" + assert data["location_url"] == "https://test.org" + assert data["location_phone"] == "+123456789" + assert data["details"] == "Lorem Ipsum" + assert data["slug"] is not None, len(data["slug"]) > 8 + assert data["status"] == 2 + assert data["keep_open"] + assert len(data["slots"]) == 3 + assert data["slots"][0]["start"] == DAY1 + "T09:00:00" + assert data["slots"][0]["duration"] == 60 + assert data["slots"][1]["start"] == DAY2 + "T09:00:00" + assert data["slots"][1]["duration"] == 15 + assert data["slots"][2]["start"] == DAY3 + "T09:00:00" + assert data["slots"][2]["duration"] == 275 + + +def test_create_appointment_on_unconnected_calendar(): + response = client.post( + "/apmt", + json={ + "appointment": {"calendar_id": 3, "title": "a", "duration": 30}, + "slots": [{"start": DAY1 + " 09:00:00", "duration": 30}], + }, + headers=headers, + ) + assert response.status_code == 403, response.text + + +def test_create_appointment_on_missing_calendar(): + response = client.post( + "/apmt", + json={ + "appointment": {"calendar_id": "999", "title": "a", "duration": 30}, + "slots": [{"start": DAY1 + " 09:00:00", "duration": 30}], + }, + headers=headers, + ) + assert response.status_code == 404, response.text + + +def test_create_appointment_on_foreign_calendar(): + response = client.post( + "/apmt", + json={ + "appointment": {"calendar_id": "2", "title": "a", "duration": 30}, + "slots": [{"start": DAY1 + " 09:00:00", "duration": 30}], + }, + headers=headers, + ) + assert response.status_code == 403, response.text + + +def test_read_appointments(): + response = client.get("/me/appointments", headers=headers) + assert response.status_code == 200, response.text + data = response.json() + assert type(data) is list + assert len(data) == 1 + data = data[0] + assert data["time_created"] is not None + assert data["time_updated"] is not None + assert data["calendar_id"] == 4 + assert data["title"] == "Appointment" + assert data["duration"] == 180 + assert data["location_type"] == 2 + assert data["location_name"] == "Location" + assert data["location_url"] == "https://test.org" + assert data["location_phone"] == "+123456789" + assert data["details"] == "Lorem Ipsum" + assert data["slug"] is not None, len(data["slug"]) > 8 + assert data["status"] == 2 + assert data["keep_open"] + assert len(data["slots"]) == 3 + assert data["slots"][0]["start"] == DAY1 + "T09:00:00" + assert data["slots"][0]["duration"] == 60 + assert data["slots"][1]["start"] == DAY2 + "T09:00:00" + assert data["slots"][1]["duration"] == 15 + assert data["slots"][2]["start"] == DAY3 + "T09:00:00" + assert data["slots"][2]["duration"] == 275 + + +def test_read_existing_appointment(): + response = client.get("/apmt/1", headers=headers) + assert response.status_code == 200, response.text + data = response.json() + assert data["time_created"] is not None + assert data["time_updated"] is not None + assert data["calendar_id"] == 4 + assert data["title"] == "Appointment" + assert data["duration"] == 180 + assert data["location_type"] == 2 + assert data["location_name"] == "Location" + assert data["location_url"] == "https://test.org" + assert data["location_phone"] == "+123456789" + assert data["details"] == "Lorem Ipsum" + assert data["slug"] is not None, len(data["slug"]) > 8 + assert data["status"] == 2 + assert data["keep_open"] + assert len(data["slots"]) == 3 + assert data["slots"][0]["start"] == DAY1 + "T09:00:00" + assert data["slots"][0]["duration"] == 60 + assert data["slots"][1]["start"] == DAY2 + "T09:00:00" + assert data["slots"][1]["duration"] == 15 + assert data["slots"][2]["start"] == DAY3 + "T09:00:00" + assert data["slots"][2]["duration"] == 275 + + +def test_read_missing_appointment(): + response = client.get("/apmt/999", headers=headers) + assert response.status_code == 404, response.text + + +def test_read_foreign_appointment(): + stmt = insert(models.Appointment).values(calendar_id="2", duration="60", title="abc", slug="test") + db = TestingSessionLocal() + db.execute(stmt) + db.commit() + response = client.get("/apmt/2", headers=headers) + assert response.status_code == 403, response.text + + +def test_update_existing_appointment(): + response = client.put( + "/apmt/1", + json={ + "appointment": { + "calendar_id": 4, + "title": "Appointmentx", + "duration": 90, + "location_type": 1, + "location_name": "Locationx", + "location_url": "https://testx.org", + "location_phone": "+1234567890", + "details": "Lorem Ipsumx", + "status": 1, + "keep_open": False, + }, + "slots": [ + {"start": DAY1 + " 11:00:00", "duration": 30}, + {"start": DAY2 + " 11:00:00", "duration": 30}, + {"start": DAY3 + " 11:00:00", "duration": 30}, + ], + }, + headers=headers, + ) + assert response.status_code == 200, response.text + data = response.json() + assert data["time_created"] is not None + assert data["time_updated"] is not None + assert data["calendar_id"] == 4 + assert data["title"] == "Appointmentx" + assert data["duration"] == 90 + assert data["location_type"] == 1 + assert data["location_name"] == "Locationx" + assert data["location_url"] == "https://testx.org" + assert data["location_phone"] == "+1234567890" + assert data["details"] == "Lorem Ipsumx" + assert data["slug"] is not None, len(data["slug"]) > 8 + assert data["status"] == 1 + assert not data["keep_open"] + assert len(data["slots"]) == 3 + assert data["slots"][0]["start"] == DAY1 + "T11:00:00" + assert data["slots"][0]["duration"] == 30 + assert data["slots"][1]["start"] == DAY2 + "T11:00:00" + assert data["slots"][1]["duration"] == 30 + assert data["slots"][2]["start"] == DAY3 + "T11:00:00" + assert data["slots"][2]["duration"] == 30 + + +def test_update_missing_appointment(): + response = client.put( + "/apmt/999", + json={ + "appointment": {"calendar_id": "2", "title": "a", "duration": 30}, + "slots": [{"start": DAY1 + " 09:00:00", "duration": 30}], + }, + headers=headers, + ) + assert response.status_code == 404, response.text + + +def test_update_foreign_appointment(): + response = client.put( + "/apmt/2", + json={ + "appointment": {"calendar_id": "2", "title": "a", "duration": 30}, + "slots": [{"start": DAY1 + " 09:00:00", "duration": 30}], + }, + headers=headers, + ) + assert response.status_code == 403, response.text + + +def test_delete_existing_appointment(): + response = client.delete("/apmt/1", headers=headers) + assert response.status_code == 200, response.text + data = response.json() + assert data["time_created"] is not None + assert data["time_updated"] is not None + assert data["calendar_id"] == 4 + assert data["title"] == "Appointmentx" + assert data["duration"] == 90 + assert data["location_type"] == 1 + assert data["location_name"] == "Locationx" + assert data["location_url"] == "https://testx.org" + assert data["location_phone"] == "+1234567890" + assert data["details"] == "Lorem Ipsumx" + assert data["slug"] is not None, len(data["slug"]) > 8 + assert data["status"] == 1 + assert not data["keep_open"] + assert len(data["slots"]) == 3 + assert data["slots"][0]["start"] == DAY1 + "T11:00:00" + assert data["slots"][0]["duration"] == 30 + assert data["slots"][1]["start"] == DAY2 + "T11:00:00" + assert data["slots"][1]["duration"] == 30 + assert data["slots"][2]["start"] == DAY3 + "T11:00:00" + assert data["slots"][2]["duration"] == 30 + response = client.get("/apmt/1", headers=headers) + assert response.status_code == 404, response.text + response = client.get("/me/appointments", headers=headers) + assert response.status_code == 200, response.text + data = response.json() + assert type(data) is list + assert len(data) == 0 + # add appointment again for further testing + client.post( + "/apmt", + json={ + "appointment": { + "calendar_id": 4, + "title": "Appointment", + "duration": 180, + "location_type": 2, + "location_name": "Location", + "location_url": "https://test.org", + "location_phone": "+123456789", + "details": "Lorem Ipsum", + "status": 2, + "keep_open": True, + "slug": "abcdef", + }, + "slots": [ + {"start": DAY1 + " 09:00:00", "duration": 60}, + {"start": DAY2 + " 09:00:00", "duration": 15}, + {"start": DAY3 + " 09:00:00", "duration": 275}, + ], + }, + headers=headers, + ) + + +def test_delete_missing_appointment(): + response = client.delete("/apmt/999", headers=headers) + assert response.status_code == 404, response.text + + +def test_delete_foreign_appointment(): + response = client.delete("/apmt/2", headers=headers) + assert response.status_code == 403, response.text + + +def test_read_public_existing_appointment(): + response = client.get("/apmt/public/abcdef") + assert response.status_code == 200, response.text + data = response.json() + assert "calendar_id" not in data + assert "status" not in data + assert data["title"] == "Appointment" + assert data["details"] == "Lorem Ipsum" + assert data["slug"] == "abcdef" + assert data["owner_name"] == "Test Account" + assert len(data["slots"]) == 3 + assert data["slots"][0]["start"] == DAY1 + "T09:00:00" + assert data["slots"][0]["duration"] == 60 + assert data["slots"][1]["start"] == DAY2 + "T09:00:00" + assert data["slots"][1]["duration"] == 15 + assert data["slots"][2]["start"] == DAY3 + "T09:00:00" + assert data["slots"][2]["duration"] == 275 + + +def test_read_public_missing_appointment(): + response = client.get("/apmt/public/missing") + assert response.status_code == 404, response.text + + +def test_attendee_selects_appointment_slot(): + response = client.put( + "/apmt/public/abcdef", + json={ + "slot_id": 1, + "attendee": { + "email": "person@test.org", + "name": "John Doe", + }, + }, + ) + assert response.status_code == 200, response.text + data = response.json() + assert data["slot_id"] == 1 + assert data["attendee"]["email"] == "person@test.org" + assert data["attendee"]["name"] == "John Doe" + + +def test_read_public_appointment_after_attendee_selection(): + response = client.get("/apmt/public/abcdef") + assert response.status_code == 200, response.text + data = response.json() + assert len(data["slots"]) == 3 + assert data["slots"][0]["attendee_id"] == 1 + + +def test_attendee_selects_slot_of_unavailable_appointment(): + response = client.put( + "/apmt/public/abcdef", + json={"slot_id": 1, "attendee": {"email": "a", "name": "b"}}, + ) + assert response.status_code == 403, response.text + + +def test_attendee_selects_slot_of_missing_appointment(): + response = client.put( + "/apmt/public/missing", + json={"slot_id": 1, "attendee": {"email": "a", "name": "b"}}, + ) + assert response.status_code == 404, response.text + + +def test_attendee_selects_missing_slot_of_existing_appointment(): + response = client.put( + "/apmt/public/abcdef", + json={"slot_id": 999, "attendee": {"email": "a", "name": "b"}}, + ) + assert response.status_code == 404, response.text + + +def test_attendee_provides_invalid_email_address(): + response = client.put( + "/apmt/public/abcdef", + json={"slot_id": 2, "attendee": {"email": "a", "name": "b"}}, + ) + assert response.status_code == 400, response.text + + +def test_get_remote_caldav_events(): + response = client.get("/rmt/cal/4/" + DAY1 + "/" + DAY3, headers=headers) + assert response.status_code == 200, response.text + data = response.json() + assert len(data) == 1 + assert data[0]["title"] == "Appointment" + assert data[0]["start"][:19] == DAY1 + " 09:00:00" + assert data[0]["end"][:19] == DAY1 + " 10:00:00" + # delete event again to prevent calendar pollution + con = CalDavConnector(conf("CALDAV_TEST_CALENDAR_URL"), conf("CALDAV_TEST_USER"), conf("CALDAV_TEST_PASS")) + n = con.delete_events(start=DAY1) + assert n == 1 + + +""" SCHEDULE tests +""" + + +def test_create_schedule_on_connected_calendar(): + response = client.post( + "/schedule", + json={ + "calendar_id": 4, + "name": "Schedule", + "location_type": 2, + "location_url": "https://test.org", + "details": "Lorem Ipsum", + "start_date": DAY1, + "end_date": DAY14, + "start_time": "10:00", + "end_time": "18:00", + "earliest_booking": 1440, + "farthest_booking": 20160, + "weekdays": [1, 2, 3, 4, 5], + "slot_duration": 30, + }, + headers=headers, + ) + assert response.status_code == 200, response.text + data = response.json() + assert data["time_created"] is not None + assert data["time_updated"] is not None + assert data["calendar_id"] == 4 + assert data["name"] == "Schedule" + assert data["location_type"] == 2 + assert data["location_url"] == "https://test.org" + assert data["details"] == "Lorem Ipsum" + assert data["start_date"] == DAY1 + assert data["end_date"] == DAY14 + assert data["start_time"] == "10:00" + assert data["end_time"] == "18:00" + assert data["earliest_booking"] == 1440 + assert data["farthest_booking"] == 20160 + assert data["weekdays"] is not None + weekdays = data["weekdays"] + assert len(weekdays) == 5 + assert weekdays == [1, 2, 3, 4, 5] + assert data["slot_duration"] == 30 + + +def test_create_schedule_on_unconnected_calendar(): + response = client.post( + "/schedule", + json={"calendar_id": 3, "name": "Schedule"}, + headers=headers, + ) + assert response.status_code == 403, response.text + + +def test_create_schedule_on_missing_calendar(): + response = client.post( + "/schedule", + json={"calendar_id": 999, "name": "Schedule"}, + headers=headers, + ) + assert response.status_code == 404, response.text + + +def test_create_schedule_on_foreign_calendar(): + response = client.post( + "/schedule", + json={"calendar_id": 2, "name": "Schedule"}, + headers=headers, + ) + assert response.status_code == 403, response.text + + +def test_read_schedules(): + response = client.get("/schedule", headers=headers) + assert response.status_code == 200, response.text + data = response.json() + assert type(data) is list + assert len(data) == 1 + data = data[0] + assert data["time_created"] is not None + assert data["time_updated"] is not None + assert data["calendar_id"] == 4 + assert data["name"] == "Schedule" + assert data["location_type"] == 2 + assert data["location_url"] == "https://test.org" + assert data["details"] == "Lorem Ipsum" + assert data["start_date"] == DAY1 + assert data["end_date"] == DAY14 + assert data["start_time"] == "10:00" + assert data["end_time"] == "18:00" + assert data["earliest_booking"] == 1440 + assert data["farthest_booking"] == 20160 + assert data["weekdays"] is not None + weekdays = data["weekdays"] + assert len(weekdays) == 5 + assert weekdays == [1, 2, 3, 4, 5] + assert data["slot_duration"] == 30 + + +def test_read_existing_schedule(): + response = client.get("/schedule/1", headers=headers) + assert response.status_code == 200, response.text + data = response.json() + assert data["time_created"] is not None + assert data["time_updated"] is not None + assert data["calendar_id"] == 4 + assert data["name"] == "Schedule" + assert data["location_type"] == 2 + assert data["location_url"] == "https://test.org" + assert data["details"] == "Lorem Ipsum" + assert data["start_date"] == DAY1 + assert data["end_date"] == DAY14 + assert data["start_time"] == "10:00" + assert data["end_time"] == "18:00" + assert data["earliest_booking"] == 1440 + assert data["farthest_booking"] == 20160 + assert data["weekdays"] is not None + weekdays = data["weekdays"] + assert len(weekdays) == 5 + assert weekdays == [1, 2, 3, 4, 5] + assert data["slot_duration"] == 30 + + +def test_read_missing_schedule(): + response = client.get("/schedule/999", headers=headers) + assert response.status_code == 404, response.text + + +def test_read_foreign_schedule(): + stmt = insert(models.Schedule).values(calendar_id=2, name="abc") + db = TestingSessionLocal() + db.execute(stmt) + db.commit() + response = client.get("/schedule/2", headers=headers) + assert response.status_code == 403, response.text + + +def test_update_existing_schedule(): + response = client.put( + "/schedule/1", + json={ + "calendar_id": 4, + "name": "Schedulex", + "location_type": 1, + "location_url": "https://testx.org", + "details": "Lorem Ipsumx", + "start_date": DAY2, + "end_date": DAY5, + "start_time": "09:00", + "end_time": "17:00", + "earliest_booking": 1000, + "farthest_booking": 20000, + "weekdays": [2, 4, 6], + "slot_duration": 60, + }, + headers=headers, + ) + assert response.status_code == 200, response.text + data = response.json() + assert data["time_created"] is not None + assert data["time_updated"] is not None + assert data["calendar_id"] == 4 + assert data["name"] == "Schedulex" + assert data["location_type"] == 1 + assert data["location_url"] == "https://testx.org" + assert data["details"] == "Lorem Ipsumx" + assert data["start_date"] == DAY2 + assert data["end_date"] == DAY5 + assert data["start_time"] == "09:00" + assert data["end_time"] == "17:00" + assert data["earliest_booking"] == 1000 + assert data["farthest_booking"] == 20000 + assert data["weekdays"] is not None + weekdays = data["weekdays"] + assert len(weekdays) == 3 + assert weekdays == [2, 4, 6] + assert data["slot_duration"] == 60 + + +def test_update_missing_schedule(): + response = client.put( + "/schedule/999", + json={"calendar_id": 1, "name": "Schedule"}, + headers=headers, + ) + assert response.status_code == 404, response.text + + +def test_update_foreign_schedule(): + response = client.put( + "/schedule/2", + json={"calendar_id": 2, "name": "Schedule"}, + headers=headers, + ) + assert response.status_code == 403, response.text + + +def test_read_schedule_availabilities(): + response = client.get("/me/signature", headers=headers) + url = response.json()["url"] + response = client.post("/schedule/public/availability", json={"url": url}) + assert response.status_code == 200, response.text + data = response.json() + assert data["title"] == "Schedulex" + assert data["details"] == "Lorem Ipsumx" + assert data["owner_name"] == "Test Account" + assert len(data["slots"]) > 5 + # TODO: some more assertions are needed here to check for correct slot generation, like: + # no slots on unchecked weekdays + # no slots before start or earliest start + # no slots after end or farthest end + # no slots during existing events + + +def test_read_schedule_availabilities_from_invalid_link(): + response = client.post("/schedule/public/availability", json={"url": "https://evil.corp"}) + assert response.status_code == 401, response.text + + +# TODO +# def test_read_schedule_availabilities_with_no_connected_calendars(): +# response = client.get("/me/signature", headers=headers) +# url = response.json()["url"] +# response = client.post("/schedule/public/availability", json={"url": url}) +# assert response.status_code == 404, response.text + + +# TODO +# def test_read_schedule_availabilities_with_no_existing_schedule(): +# response = client.get("/me/signature", headers=headers) +# url = response.json()["url"] +# response = client.post("/schedule/public/availability", json={"url": url}) +# assert response.status_code == 404, response.text + + +# TODO +# def test_read_schedule_availabilities_on_disabled_schedule(): +# response = client.get("/me/signature", headers=headers) +# url = response.json()["url"] +# response = client.post("/schedule/public/availability", json={"url": url}) +# assert response.status_code == 404, response.text + + +# TODO +# def test_read_schedule_availabilities_with_no_actual_booking_slots(): +# response = client.get("/me/signature", headers=headers) +# url = response.json()["url"] +# response = client.post("/schedule/public/availability", json={"url": url}) +# assert response.status_code == 404, response.text + + +""" MISCELLANEOUS tests +""" + + +def test_get_invitation_ics_file(): + response = client.get("/apmt/serve/ics/abcdef/1") + assert response.status_code == 200, response.text + data = response.json() + assert data["name"] == "invite" + assert data["content_type"] == "text/calendar" + assert "data" in data + + +def test_get_invitation_ics_file_for_missing_appointment(): + response = client.get("/apmt/serve/ics/missing/1") + assert response.status_code == 404, response.text + + +def test_get_invitation_ics_file_for_missing_slot(): + response = client.get("/apmt/serve/ics/abcdef/999") + assert response.status_code == 404, response.text diff --git a/docs/README.md b/docs/README.md index e7373633..5ca13c59 100644 --- a/docs/README.md +++ b/docs/README.md @@ -115,6 +115,7 @@ erDiagram date time_updated "UTC timestamp of last schedule modification" } SCHEDULES ||--|{ AVAILABILITIES : hold_custom + SCHEDULES ||--|{ SLOTS : provide_on_request AVAILABILITIES { int id PK "Unique availability key" int schedule_id FK "Schedule this availability is for" @@ -126,15 +127,19 @@ erDiagram date time_created "UTC timestamp of schedule creation" date time_updated "UTC timestamp of last schedule modification" } - APPOINTMENTS ||--|{ SLOTS : provide + APPOINTMENTS ||--|{ SLOTS : provide_manual_selected SLOTS { int id PK "Unique key of available time slot" int appointment_id FK "Appointment this slot was provided for" + int schedule_id FK "Schedule this slot was requested for" int attendee_id FK "Attendee who selected this slot" int subscriber_id FK "Subscriber who chose this slot" date time_updated "UTC timestamp of last slot modification" date start "UTC timestamp of slot starting time" int duration "Custom slot duration, number of minutes [10-600]" + string booking_tkn "Temp storage for verifying booking slot" + date booking_expires_at "Booking expiration date" + enum booking_status "[none, requested, booked]" } SUBSCRIBERS ||--o{ SLOTS : choose ATTENDEES ||--o{ SLOTS : select diff --git a/frontend/.env.example b/frontend/.env.example index 555a6525..582f788b 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -1,18 +1,18 @@ -# Appointment frontend configuration. - -# -- Frontend -- -VUE_APP_BASE_URL=localhost:8080 -VUE_APP_SHORT_BASE_URL=localhost:8080/user - -# -- Backend API -- -VUE_APP_API_URL=localhost -VUE_APP_API_PORT=8090 -VUE_APP_API_SECURE=false - -# -- Auth0 -- -VUE_APP_AUTH0_DOMAIN= -VUE_APP_AUTH0_CLIENT_ID= -VUE_APP_AUTH0_AUDIENCE= - -# -- Sentry -- -VUE_APP_SENTRY_DSN= +# Appointment frontend configuration. + +# -- Frontend -- +VUE_APP_BASE_URL=localhost:8080 +VUE_APP_SHORT_BASE_URL=localhost:8080/user + +# -- Backend API -- +VUE_APP_API_URL=localhost +VUE_APP_API_PORT=8090 +VUE_APP_API_SECURE=false + +# -- Auth0 -- +VUE_APP_AUTH0_DOMAIN= +VUE_APP_AUTH0_CLIENT_ID= +VUE_APP_AUTH0_AUDIENCE= + +# -- Sentry -- +VUE_APP_SENTRY_DSN= diff --git a/frontend/.env.staging.example b/frontend/.env.staging.example index 2a707c36..1133075f 100644 --- a/frontend/.env.staging.example +++ b/frontend/.env.staging.example @@ -1,9 +1,9 @@ -# Production env config, do not put secrets in here !! -VUE_APP_API_URL=stage.appointment.day/api/v1/ -VUE_APP_BASE_URL=stage.appointment.day -VUE_APP_API_SECURE=true -VUE_APP_SHORT_BASE_URL=https://stage.apmt.day - -VUE_APP_AUTH0_DOMAIN=thunderbird.eu.auth0.com -VUE_APP_AUTH0_CLIENT_ID=Dvw6O6hPwXg7jZD7p7yAAnLXR2PXiQHF -VUE_APP_AUTH0_AUDIENCE=http://127.0.0.1:8000/ +# Production env config, do not put secrets in here !! +VUE_APP_API_URL=stage.appointment.day/api/v1/ +VUE_APP_BASE_URL=stage.appointment.day +VUE_APP_API_SECURE=true +VUE_APP_SHORT_BASE_URL=https://stage.apmt.day + +VUE_APP_AUTH0_DOMAIN=thunderbird.eu.auth0.com +VUE_APP_AUTH0_CLIENT_ID=Dvw6O6hPwXg7jZD7p7yAAnLXR2PXiQHF +VUE_APP_AUTH0_AUDIENCE=http://127.0.0.1:8000/ diff --git a/frontend/.eslintignore b/frontend/.eslintignore index 466c1d81..27160a72 100644 --- a/frontend/.eslintignore +++ b/frontend/.eslintignore @@ -1,4 +1,4 @@ -src/assets -src/elements/arts -src/locales +src/assets +src/elements/arts +src/locales node_modules \ No newline at end of file diff --git a/frontend/.eslintrc.js b/frontend/.eslintrc.js index d7fa82a2..23174004 100644 --- a/frontend/.eslintrc.js +++ b/frontend/.eslintrc.js @@ -1,34 +1,34 @@ -module.exports = { - env: { - browser: true, - es2021: true, - }, - extends: [ - 'plugin:vue/vue3-essential', - 'airbnb-base', - ], - overrides: [], - parserOptions: { - ecmaVersion: 'latest', - sourceType: 'module', - }, - plugins: [ - 'vue', - ], - rules: { - 'import/extensions': ['error', 'ignorePackages', { - '': 'never', - js: 'never', - vue: 'off', // TODO: once migrated to Vite, we should set this to 'always' - }], - 'max-len': ['error', { code: 120 }], - 'no-param-reassign': 'off', - }, - settings: { - 'import/resolver': { - webpack: { - config: require.resolve('@vue/cli-service/webpack.config.js'), - }, - }, - }, -}; +module.exports = { + env: { + browser: true, + es2021: true, + }, + extends: [ + 'plugin:vue/vue3-essential', + 'airbnb-base', + ], + overrides: [], + parserOptions: { + ecmaVersion: 'latest', + sourceType: 'module', + }, + plugins: [ + 'vue', + ], + rules: { + 'import/extensions': ['error', 'ignorePackages', { + '': 'never', + js: 'never', + vue: 'off', // TODO: once migrated to Vite, we should set this to 'always' + }], + 'max-len': ['error', { code: 120 }], + 'no-param-reassign': 'off', + }, + settings: { + 'import/resolver': { + webpack: { + config: require.resolve('@vue/cli-service/webpack.config.js'), + }, + }, + }, +}; diff --git a/frontend/.gitignore b/frontend/.gitignore old mode 100755 new mode 100644 index 694a5904..90f89baa --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -1,24 +1,24 @@ -.DS_Store -node_modules -/dist -.yarn - - -# local env files -.env.local -.env.*.local - -# Log files -npm-debug.log* -yarn-debug.log* -yarn-error.log* -pnpm-debug.log* - -# Editor directories and files -.idea -.vscode -*.suo -*.ntvs* -*.njsproj -*.sln -*.sw? +.DS_Store +node_modules +/dist +.yarn + + +# local env files +.env.local +.env.*.local + +# Log files +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* + +# Editor directories and files +.idea +.vscode +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 6294c3ca..e2bf6d97 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -1,13 +1,13 @@ -FROM node:lts-alpine - -WORKDIR /app - -ENV PATH /app/node_modules/.bin:$PATH - -# Get git -RUN apk add --no-cache git - -COPY . . -RUN yarn install - +FROM node:lts-alpine + +WORKDIR /app + +ENV PATH /app/node_modules/.bin:$PATH + +# Get git +RUN apk add --no-cache git + +COPY . . +RUN yarn install + CMD vue-cli-service serve \ No newline at end of file diff --git a/frontend/README.md b/frontend/README.md old mode 100755 new mode 100644 diff --git a/frontend/docker/etc/nginx/conf.d/appointments.conf b/frontend/docker/etc/nginx/conf.d/appointments.conf index fe3c3c97..f1865df5 100644 --- a/frontend/docker/etc/nginx/conf.d/appointments.conf +++ b/frontend/docker/etc/nginx/conf.d/appointments.conf @@ -1,44 +1,44 @@ -server { - listen 80; - listen [::]:80; - server_name localhost; - - #access_log /var/log/nginx/host.access.log main; - - # Backend API proxy - location ^~ /api/v1/ { - # Remove our fake /api/v1/ prefix for FastAPI - rewrite ^/api/v1/(.*)$ /$1 break; - proxy_pass http://127.0.0.1:5000; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header Host $http_host; - proxy_http_version 1.1; - } - # Frontend Vue static files - location / { - root /usr/share/nginx/html; - try_files $uri $uri/ /index.html; - #auth_basic "Restricted Content"; - #auth_basic_user_file /etc/nginx/.htpasswd; - } - - #error_page 404 /404.html; - - # redirect server error pages to the static page /50x.html - # - #error_page 500 502 503 504 /50x.html; - #location = /50x.html { - # root /usr/share/nginx/html; - #} -} - -# Stage Shortlink Redirect -server { - listen 80; - listen [::]:80; - server_name stage.apmt.local stage.apmt.day; - location / { - # Transform stage.apmt.day/ to stage.appointment.day/user/ - rewrite ^(.*)$ https://stage.appointment.day/user$1 redirect; - } +server { + listen 80; + listen [::]:80; + server_name localhost; + + #access_log /var/log/nginx/host.access.log main; + + # Backend API proxy + location ^~ /api/v1/ { + # Remove our fake /api/v1/ prefix for FastAPI + rewrite ^/api/v1/(.*)$ /$1 break; + proxy_pass http://127.0.0.1:5000; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header Host $http_host; + proxy_http_version 1.1; + } + # Frontend Vue static files + location / { + root /usr/share/nginx/html; + try_files $uri $uri/ /index.html; + #auth_basic "Restricted Content"; + #auth_basic_user_file /etc/nginx/.htpasswd; + } + + #error_page 404 /404.html; + + # redirect server error pages to the static page /50x.html + # + #error_page 500 502 503 504 /50x.html; + #location = /50x.html { + # root /usr/share/nginx/html; + #} +} + +# Stage Shortlink Redirect +server { + listen 80; + listen [::]:80; + server_name stage.apmt.local stage.apmt.day; + location / { + # Transform stage.apmt.day/ to stage.appointment.day/user/ + rewrite ^(.*)$ https://stage.appointment.day/user$1 redirect; + } } \ No newline at end of file diff --git a/frontend/jsconfig.json b/frontend/jsconfig.json old mode 100755 new mode 100644 diff --git a/frontend/package.json b/frontend/package.json old mode 100755 new mode 100644 diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js index 485780a6..f1c8dac8 100644 --- a/frontend/postcss.config.js +++ b/frontend/postcss.config.js @@ -1,6 +1,6 @@ -module.exports = { - plugins: { - tailwindcss: {}, - autoprefixer: {}, - } -} +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + } +} diff --git a/frontend/public/appointment_logo.svg b/frontend/public/appointment_logo.svg index b073221f..624d1fbb 100644 --- a/frontend/public/appointment_logo.svg +++ b/frontend/public/appointment_logo.svg @@ -1,10 +1,10 @@ - - - - - - - - - - + + + + + + + + + + diff --git a/frontend/public/index.html b/frontend/public/index.html old mode 100755 new mode 100644 diff --git a/frontend/src/App.vue b/frontend/src/App.vue old mode 100755 new mode 100644 diff --git a/frontend/src/assets/fonts/Apache License.txt b/frontend/src/assets/fonts/Apache License.txt index 989e2c59..b1fac45f 100644 --- a/frontend/src/assets/fonts/Apache License.txt +++ b/frontend/src/assets/fonts/Apache License.txt @@ -1,201 +1,201 @@ -Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - - APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - - Copyright [yyyy] [name of copyright owner] - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and +Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and limitations under the License. \ No newline at end of file diff --git a/frontend/src/assets/main.css b/frontend/src/assets/main.css index a4a62b1a..6b81ca77 100644 --- a/frontend/src/assets/main.css +++ b/frontend/src/assets/main.css @@ -1,98 +1,98 @@ -@tailwind base; -@tailwind components; -@tailwind utilities; - - -@layer base { - input, select, textarea { - @apply transition-all dark:text-white bg-white dark:bg-gray-700 !border-gray-300 dark:!border-gray-500 focus:!ring-teal-500 focus:!border-teal-500 checked:!border-teal-500; - } - - hr { - @apply border-t-gray-300 dark:border-t-gray-500 - } - - @font-face { - font-family: 'Raleway'; - src: url('./fonts/Raleway-Regular.ttf'); - font-weight: 400; - } - @font-face { - font-family: 'Open Sans'; - src: url('./fonts/OpenSans-LightItalic.ttf'); - font-weight: 300; - font-style: italic; - } - @font-face { - font-family: 'Open Sans'; - src: url('./fonts/OpenSans-Light.ttf'); - font-weight: 300; - } - @font-face { - font-family: 'Open Sans'; - src: url('./fonts/OpenSans-Italic.ttf'); - font-weight: 400; - font-style: italic; - } - @font-face { - font-family: 'Open Sans'; - src: url('./fonts/OpenSans-Regular.ttf'); - font-weight: 400; - } - @font-face { - font-family: 'Open Sans'; - src: url('./fonts/OpenSans-MediumItalic.ttf'); - font-weight: 500; - font-style: italic; - } - @font-face { - font-family: 'Open Sans'; - src: url('./fonts/OpenSans-Medium.ttf'); - font-weight: 500; - } - @font-face { - font-family: 'Open Sans'; - src: url('./fonts/OpenSans-SemiBoldItalic.ttf'); - font-weight: 600; - font-style: italic; - } - @font-face { - font-family: 'Open Sans'; - src: url('./fonts/OpenSans-SemiBold.ttf'); - font-weight: 600; - } - @font-face { - font-family: 'Open Sans'; - src: url('./fonts/OpenSans-BoldItalic.ttf'); - font-weight: 700; - font-style: italic; - } - @font-face { - font-family: 'Open Sans'; - src: url('./fonts/OpenSans-Bold.ttf'); - font-weight: 700; - } - @font-face { - font-family: 'Open Sans'; - src: url('./fonts/OpenSans-ExtraBoldItalic.ttf'); - font-weight: 800; - font-style: italic; - } - @font-face { - font-family: 'Open Sans'; - src: url('./fonts/OpenSans-ExtraBold.ttf'); - font-weight: 800; - } -} - -@layer components { - .position-center { - @apply top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2; - } - .flex-center { - @apply flex justify-center items-center; - } - .place-holder { - @apply placeholder:text-gray-300 dark:placeholder:text-gray-500; - } -} +@tailwind base; +@tailwind components; +@tailwind utilities; + + +@layer base { + input, select, textarea { + @apply transition-all dark:text-white bg-white dark:bg-gray-700 !border-gray-300 dark:!border-gray-500 focus:!ring-teal-500 focus:!border-teal-500 checked:!border-teal-500; + } + + hr { + @apply border-t-gray-300 dark:border-t-gray-500 + } + + @font-face { + font-family: 'Raleway'; + src: url('./fonts/Raleway-Regular.ttf'); + font-weight: 400; + } + @font-face { + font-family: 'Open Sans'; + src: url('./fonts/OpenSans-LightItalic.ttf'); + font-weight: 300; + font-style: italic; + } + @font-face { + font-family: 'Open Sans'; + src: url('./fonts/OpenSans-Light.ttf'); + font-weight: 300; + } + @font-face { + font-family: 'Open Sans'; + src: url('./fonts/OpenSans-Italic.ttf'); + font-weight: 400; + font-style: italic; + } + @font-face { + font-family: 'Open Sans'; + src: url('./fonts/OpenSans-Regular.ttf'); + font-weight: 400; + } + @font-face { + font-family: 'Open Sans'; + src: url('./fonts/OpenSans-MediumItalic.ttf'); + font-weight: 500; + font-style: italic; + } + @font-face { + font-family: 'Open Sans'; + src: url('./fonts/OpenSans-Medium.ttf'); + font-weight: 500; + } + @font-face { + font-family: 'Open Sans'; + src: url('./fonts/OpenSans-SemiBoldItalic.ttf'); + font-weight: 600; + font-style: italic; + } + @font-face { + font-family: 'Open Sans'; + src: url('./fonts/OpenSans-SemiBold.ttf'); + font-weight: 600; + } + @font-face { + font-family: 'Open Sans'; + src: url('./fonts/OpenSans-BoldItalic.ttf'); + font-weight: 700; + font-style: italic; + } + @font-face { + font-family: 'Open Sans'; + src: url('./fonts/OpenSans-Bold.ttf'); + font-weight: 700; + } + @font-face { + font-family: 'Open Sans'; + src: url('./fonts/OpenSans-ExtraBoldItalic.ttf'); + font-weight: 800; + font-style: italic; + } + @font-face { + font-family: 'Open Sans'; + src: url('./fonts/OpenSans-ExtraBold.ttf'); + font-weight: 800; + } +} + +@layer components { + .position-center { + @apply top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2; + } + .flex-center { + @apply flex justify-center items-center; + } + .place-holder { + @apply placeholder:text-gray-300 dark:placeholder:text-gray-500; + } +} diff --git a/frontend/src/components/BookingModal.vue b/frontend/src/components/BookingModal.vue index 41c3ce60..8646165f 100644 --- a/frontend/src/components/BookingModal.vue +++ b/frontend/src/components/BookingModal.vue @@ -8,7 +8,9 @@
- {{ !success ? t('heading.bookSelection') : t('heading.eventBooked') }} + {{ t('heading.bookSelection') }} + {{ t('heading.eventBooked') }} + {{ t('info.bookingSuccessfullyRequested') }}
{{ event.title }}:
@@ -48,7 +50,8 @@
- {{ t('text.invitationSentToAddress', { address: attendee.email }) }} + {{ t('text.invitationSentToAddress', { address: attendee.email }) }} + {{ t('text.requestInformationSentToOwner') }}
@@ -64,7 +67,7 @@ @click="bookIt" /> @@ -96,6 +99,7 @@ const props = defineProps({ open: Boolean, // modal state event: Object, // event data to display and book success: Boolean, // true if booking was successful + request: Boolean, // true if booking was only requested instead of executed directly }); // component emits diff --git a/frontend/src/components/SettingsAccount.vue b/frontend/src/components/SettingsAccount.vue index 07f76232..2cbc519d 100644 --- a/frontend/src/components/SettingsAccount.vue +++ b/frontend/src/components/SettingsAccount.vue @@ -27,13 +27,18 @@
-
+
{{ t('label.downloadTheIcsFile') }}
-
+
{{ t('info.invitationWasSent') }}
{{ attendee.email }} @@ -144,6 +146,7 @@ @book="bookEvent" @download="downloadIcs" @close="closeBookingModal" + request />
@@ -155,6 +158,7 @@ import { ref, inject, onMounted, computed } from 'vue'; import { useI18n } from 'vue-i18n'; import { useRoute, useRouter } from 'vue-router'; import ArtInvalidLink from '@/elements/arts/ArtInvalidLink'; +import LoadingSpinner from '@/elements/LoadingSpinner'; import ArtSuccessfulBooking from '@/elements/arts/ArtSuccessfulBooking'; import BookingModal from '@/components/BookingModal'; import CalendarDay from '@/components/CalendarDay'; @@ -307,7 +311,7 @@ const bookEvent = async (attendeeData) => { }, attendee: attendeeData, }; - const { error } = await call('schedule/public/availability').put({ s_a: obj, url: window.location.href }).json(); + const { error } = await call('schedule/public/availability/request').put({ s_a: obj, url: window.location.href }).json(); if (error.value) { return true; } diff --git a/frontend/src/views/CalendarView.vue b/frontend/src/views/CalendarView.vue old mode 100755 new mode 100644 diff --git a/frontend/src/views/ProfileView.vue b/frontend/src/views/ProfileView.vue old mode 100755 new mode 100644 diff --git a/frontend/src/views/SettingsView.vue b/frontend/src/views/SettingsView.vue old mode 100755 new mode 100644 diff --git a/frontend/vue.config.js b/frontend/vue.config.js old mode 100755 new mode 100644 diff --git a/frontend/yarn.lock b/frontend/yarn.lock old mode 100755 new mode 100644