├── .azdo └── pipelines │ └── azure-dev.yml ├── .devcontainer └── devcontainer.json ├── .gitattributes ├── .github └── workflows │ └── azure-dev.yml ├── .gitignore ├── .vscode ├── extensions.json ├── launch.json └── tasks.json ├── LICENSE ├── NOTICE.txt ├── README.md ├── assets ├── resources-with-apim.png ├── resources.png ├── urls.png └── web.png ├── azure.yaml ├── infra ├── abbreviations.json ├── app │ ├── api-appservice-avm.bicep │ ├── db-avm.bicep │ └── web-appservice-avm.bicep ├── main.bicep └── main.parameters.json ├── openapi.yaml ├── src ├── api │ ├── .gitignore │ ├── .vscode │ │ ├── launch.json │ │ └── settings.json │ ├── Dockerfile │ ├── README.md │ ├── openapi.yaml │ ├── pyproject.toml │ ├── requirements-test.txt │ ├── requirements.txt │ ├── tests │ │ ├── conftest.py │ │ └── test_main.py │ └── todo │ │ ├── __init__.py │ │ ├── app.py │ │ ├── models.py │ │ └── routes.py └── web │ ├── .dockerignore │ ├── .eslintrc.cjs │ ├── .gitignore │ ├── Dockerfile │ ├── README.md │ ├── index.html │ ├── nginx │ └── nginx.conf │ ├── package-lock.json │ ├── package.json │ ├── public │ ├── favicon.ico │ └── manifest.json │ ├── src │ ├── @types │ │ └── window.d.ts │ ├── App.css │ ├── App.tsx │ ├── actions │ │ ├── actionCreators.ts │ │ ├── common.ts │ │ ├── itemActions.ts │ │ └── listActions.ts │ ├── components │ │ ├── telemetry.tsx │ │ ├── telemetryContext.ts │ │ ├── telemetryWithAppInsights.tsx │ │ ├── todoContext.ts │ │ ├── todoItemDetailPane.tsx │ │ ├── todoItemListPane.tsx │ │ └── todoListMenu.tsx │ ├── config │ │ └── index.ts │ ├── index.css │ ├── index.tsx │ ├── layout │ │ ├── header.tsx │ │ ├── layout.tsx │ │ └── sidebar.tsx │ ├── models │ │ ├── applicationState.ts │ │ ├── index.ts │ │ ├── todoItem.ts │ │ └── todoList.ts │ ├── pages │ │ └── homePage.tsx │ ├── react-app-env.d.ts │ ├── reducers │ │ ├── index.ts │ │ ├── listsReducer.ts │ │ ├── selectedItemReducer.ts │ │ └── selectedListReducer.ts │ ├── reportWebVitals.ts │ ├── services │ │ ├── itemService.ts │ │ ├── listService.ts │ │ ├── restService.ts │ │ └── telemetryService.ts │ ├── setupTests.ts │ └── ux │ │ ├── styles.ts │ │ └── theme.ts │ ├── tsconfig.json │ ├── tsconfig.node.json │ ├── vite.config.ts │ └── web.config └── tests ├── .gitignore ├── README.md ├── package-lock.json ├── package.json ├── playwright.config.ts └── todo.spec.ts /.azdo/pipelines/azure-dev.yml: -------------------------------------------------------------------------------- 1 | # Run when commits are pushed to mainline branch (main or master) 2 | # Set this to the mainline branch you are using 3 | trigger: 4 | - main 5 | - master 6 | 7 | # Azure Pipelines workflow to deploy to Azure using azd 8 | # To configure required secrets and service connection for connecting to Azure, simply run `azd pipeline config --provider azdo` 9 | # Task "Install azd" needs to install setup-azd extension for azdo - https://marketplace.visualstudio.com/items?itemName=ms-azuretools.azd 10 | # See below for alternative task to install azd if you can't install above task in your organization 11 | 12 | pool: 13 | vmImage: ubuntu-latest 14 | 15 | steps: 16 | - task: setup-azd@1 17 | displayName: Install azd 18 | 19 | # If you can't install above task in your organization, you can comment it and uncomment below task to install azd 20 | # - task: Bash@3 21 | # displayName: Install azd 22 | # inputs: 23 | # targetType: 'inline' 24 | # script: | 25 | # curl -fsSL https://aka.ms/install-azd.sh | bash 26 | 27 | # azd delegate auth to az to use service connection with AzureCLI@2 28 | - pwsh: | 29 | azd config set auth.useAzCliAuth "true" 30 | displayName: Configure AZD to Use AZ CLI Authentication. 31 | 32 | - task: AzureCLI@2 33 | displayName: Provision Infrastructure 34 | inputs: 35 | azureSubscription: azconnection 36 | scriptType: bash 37 | scriptLocation: inlineScript 38 | inlineScript: | 39 | azd provision --no-prompt 40 | env: 41 | AZURE_SUBSCRIPTION_ID: $(AZURE_SUBSCRIPTION_ID) 42 | AZURE_ENV_NAME: $(AZURE_ENV_NAME) 43 | AZURE_LOCATION: $(AZURE_LOCATION) 44 | AZD_INITIAL_ENVIRONMENT_CONFIG: $(secrets.AZD_INITIAL_ENVIRONMENT_CONFIG) 45 | 46 | - task: AzureCLI@2 47 | displayName: Deploy Application 48 | inputs: 49 | azureSubscription: azconnection 50 | scriptType: bash 51 | scriptLocation: inlineScript 52 | inlineScript: | 53 | azd deploy --no-prompt 54 | env: 55 | AZURE_SUBSCRIPTION_ID: $(AZURE_SUBSCRIPTION_ID) 56 | AZURE_ENV_NAME: $(AZURE_ENV_NAME) 57 | AZURE_LOCATION: $(AZURE_LOCATION) 58 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Azure Developer CLI", 3 | "image": "mcr.microsoft.com/devcontainers/python:3.10-bullseye", 4 | "features": { 5 | "ghcr.io/devcontainers/features/docker-in-docker:2": { 6 | }, 7 | "ghcr.io/devcontainers/features/node:1": { 8 | "version": "18", 9 | "nodeGypDependencies": false 10 | }, 11 | "ghcr.io/azure/azure-dev/azd:latest": {} 12 | }, 13 | "customizations": { 14 | "vscode": { 15 | "extensions": [ 16 | "GitHub.vscode-github-actions", 17 | "ms-azuretools.azure-dev", 18 | "ms-azuretools.vscode-azurefunctions", 19 | "ms-azuretools.vscode-bicep", 20 | "ms-azuretools.vscode-docker", 21 | "ms-python.python", 22 | "ms-vscode.vscode-node-azure-pack" 23 | ] 24 | } 25 | }, 26 | "forwardPorts": [ 27 | 3000, 28 | 3100 29 | ], 30 | "postCreateCommand": "", 31 | "remoteUser": "vscode", 32 | "hostRequirements": { 33 | "memory": "8gb" 34 | } 35 | } -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | *.{cmd,[cC][mM][dD]} text eol=crlf 3 | *.{bat,[bB][aA][tT]} text eol=crlf -------------------------------------------------------------------------------- /.github/workflows/azure-dev.yml: -------------------------------------------------------------------------------- 1 | on: 2 | workflow_dispatch: 3 | push: 4 | # Run when commits are pushed to mainline branch (main or master) 5 | # Set this to the mainline branch you are using 6 | branches: 7 | - main 8 | - master 9 | 10 | # GitHub Actions workflow to deploy to Azure using azd 11 | # To configure required secrets for connecting to Azure, simply run `azd pipeline config` 12 | 13 | # Set up permissions for deploying with secretless Azure federated credentials 14 | # https://learn.microsoft.com/en-us/azure/developer/github/connect-from-azure?tabs=azure-portal%2Clinux#set-up-azure-login-with-openid-connect-authentication 15 | permissions: 16 | id-token: write 17 | contents: read 18 | 19 | jobs: 20 | build: 21 | runs-on: ubuntu-latest 22 | env: 23 | AZURE_CLIENT_ID: ${{ vars.AZURE_CLIENT_ID }} 24 | AZURE_TENANT_ID: ${{ vars.AZURE_TENANT_ID }} 25 | AZURE_SUBSCRIPTION_ID: ${{ vars.AZURE_SUBSCRIPTION_ID }} 26 | AZURE_ENV_NAME: ${{ vars.AZURE_ENV_NAME }} 27 | AZURE_LOCATION: ${{ vars.AZURE_LOCATION }} 28 | steps: 29 | - name: Checkout 30 | uses: actions/checkout@v4 31 | 32 | - name: Install azd 33 | uses: Azure/setup-azd@v2 34 | 35 | - name: Log in with Azure (Federated Credentials) 36 | if: ${{ env.AZURE_CLIENT_ID != '' }} 37 | run: | 38 | azd auth login ` 39 | --client-id "$Env:AZURE_CLIENT_ID" ` 40 | --federated-credential-provider "github" ` 41 | --tenant-id "$Env:AZURE_TENANT_ID" 42 | shell: pwsh 43 | 44 | - name: Log in with Azure (Client Credentials) 45 | if: ${{ env.AZURE_CREDENTIALS != '' }} 46 | run: | 47 | $info = $Env:AZURE_CREDENTIALS | ConvertFrom-Json -AsHashtable; 48 | Write-Host "::add-mask::$($info.clientSecret)" 49 | 50 | azd auth login ` 51 | --client-id "$($info.clientId)" ` 52 | --client-secret "$($info.clientSecret)" ` 53 | --tenant-id "$($info.tenantId)" 54 | shell: pwsh 55 | env: 56 | AZURE_CREDENTIALS: ${{ secrets.AZURE_CREDENTIALS }} 57 | 58 | - name: Provision Infrastructure 59 | run: azd provision --no-prompt 60 | env: 61 | AZD_INITIAL_ENVIRONMENT_CONFIG: ${{ secrets.AZD_INITIAL_ENVIRONMENT_CONFIG }} 62 | 63 | - name: Deploy Application 64 | run: azd deploy --no-prompt 65 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .azure -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "ms-azuretools.azure-dev" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "Debug Web", 9 | "request": "launch", 10 | "type": "msedge", 11 | "webRoot": "${workspaceFolder}/src/web/src", 12 | "url": "http://localhost:3000", 13 | "sourceMapPathOverrides": { 14 | "webpack:///src/*": "${webRoot}/*" 15 | }, 16 | }, 17 | 18 | { 19 | "name": "Debug API", 20 | "type": "python", 21 | "request": "launch", 22 | "module": "uvicorn", 23 | "cwd": "${workspaceFolder}/src/api", 24 | "args": [ 25 | "todo.app:app", 26 | "--port", "3100", 27 | "--reload" 28 | ], 29 | "justMyCode": true, 30 | "python": "${workspaceFolder}/src/api/api_env/bin/python3", 31 | "envFile": "${input:dotEnvFilePath}", 32 | "windows": { 33 | "python": "${workspaceFolder}/src/api/api_env/scripts/python.exe" 34 | }, 35 | "preLaunchTask": "Restore API", 36 | "env": { 37 | "API_ENVIRONMENT":"develop" 38 | } 39 | } 40 | ], 41 | 42 | "inputs": [ 43 | { 44 | "id": "dotEnvFilePath", 45 | "type": "command", 46 | "command": "azure-dev.commands.getDotEnvFilePath" 47 | } 48 | ] 49 | } 50 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "label": "Start Web", 6 | "type": "dotenv", 7 | "targetTasks": [ 8 | "Restore Web", 9 | "Web npm start" 10 | ], 11 | "file": "${input:dotEnvFilePath}" 12 | }, 13 | { 14 | "label": "Restore Web", 15 | "type": "shell", 16 | "command": "azd restore web", 17 | "presentation": { 18 | "reveal": "silent" 19 | }, 20 | "problemMatcher": [] 21 | }, 22 | { 23 | "label": "Web npm start", 24 | "detail": "Helper task--use 'Start Web' task to ensure environment is set up correctly", 25 | "type": "shell", 26 | "command": "npx -y cross-env VITE_APPLICATIONINSIGHTS_CONNECTION_STRING=\"$APPLICATIONINSIGHTS_CONNECTION_STRING\" npm run dev", 27 | "options": { 28 | "cwd": "${workspaceFolder}/src/web/", 29 | "env": { 30 | "VITE_API_BASE_URL": "http://localhost:3100", 31 | "BROWSER": "none" 32 | } 33 | }, 34 | "presentation": { 35 | "panel": "dedicated", 36 | }, 37 | "problemMatcher": [] 38 | }, 39 | 40 | { 41 | "label": "Start API", 42 | "type": "dotenv", 43 | "targetTasks": [ 44 | "Restore API", 45 | "API uvicorn launch" 46 | ], 47 | "file": "${input:dotEnvFilePath}" 48 | }, 49 | { 50 | "label": "Restore API", 51 | "type": "shell", 52 | "command": "azd restore api", 53 | "presentation": { 54 | "reveal": "silent" 55 | }, 56 | "problemMatcher": [] 57 | }, 58 | { 59 | "label": "API uvicorn launch", 60 | "detail": "Helper task--use 'Start API' task to ensure environment is set up correctly", 61 | "type": "shell", 62 | "command": "${workspaceFolder}/src/api/api_env/bin/uvicorn todo.app:app --port 3100 --reload", 63 | "options": { 64 | "cwd": "${workspaceFolder}/src/api/", 65 | "env": { 66 | "API_ENVIRONMENT": "develop" 67 | } 68 | }, 69 | "windows": { 70 | "command": "${workspaceFolder}/src/api/api_env/scripts/uvicorn todo.app:app --port 3100 --reload" 71 | }, 72 | "presentation": { 73 | "panel": "dedicated", 74 | }, 75 | "problemMatcher": [] 76 | }, 77 | 78 | { 79 | "label": "Start API and Web", 80 | "dependsOn":[ 81 | "Start API", 82 | "Start Web" 83 | ], 84 | "problemMatcher": [] 85 | } 86 | ], 87 | 88 | "inputs": [ 89 | { 90 | "id": "dotEnvFilePath", 91 | "type": "command", 92 | "command": "azure-dev.commands.getDotEnvFilePath" 93 | } 94 | ] 95 | } 96 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright 2022 (c) Microsoft Corporation. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | --- 2 | page_type: sample 3 | languages: 4 | - azdeveloper 5 | - python 6 | - bicep 7 | - typescript 8 | - html 9 | products: 10 | - azure 11 | - azure-cosmos-db 12 | - azure-app-service 13 | - azure-monitor 14 | - azure-pipelines 15 | urlFragment: todo-python-mongo 16 | name: React Web App with Python API and MongoDB on Azure 17 | description: A complete ToDo app with Python FastAPI and Azure Cosmos API for MongoDB for storage. Uses Azure Developer CLI (azd) to build, deploy, and monitor 18 | --- 19 | 20 | 21 | # React Web App with Python API and MongoDB on Azure 22 | 23 | [![Open in GitHub Codespaces](https://img.shields.io/static/v1?style=for-the-badge&label=GitHub+Codespaces&message=Open&color=brightgreen&logo=github)](https://codespaces.new/azure-samples/todo-python-mongo) 24 | [![Open in Dev Container](https://img.shields.io/static/v1?style=for-the-badge&label=Dev+Containers&message=Open&color=blue&logo=visualstudiocode)](https://vscode.dev/redirect?url=vscode://ms-vscode-remote.remote-containers/cloneInVolume?url=https://github.com/azure-samples/todo-python-mongo) 25 | 26 | A blueprint for getting a React web app with Python (FastAPI) API and a MongoDB API in Cosmos database running on Azure. The blueprint includes sample application code (a ToDo web app) which can be removed and replaced with your own application code. Add your own source code and leverage the Infrastructure as Code assets (written in Bicep) to get up and running quickly. 27 | 28 | Let's jump in and get this up and running in Azure. When you are finished, you will have a fully functional web app deployed to the cloud. In later steps, you'll see how to setup a pipeline and monitor the application. 29 | 30 | !["Screenshot of deployed ToDo app"](assets/web.png) 31 | 32 | Screenshot of the deployed ToDo app 33 | 34 | ### Prerequisites 35 | > This template will create infrastructure and deploy code to Azure. If you don't have an Azure Subscription, you can sign up for a [free account here](https://azure.microsoft.com/free/). Make sure you have contributor role to the Azure subscription. 36 | 37 | The following prerequisites are required to use this application. Please ensure that you have them all installed locally, or open the project in Github Codespaces or [VS Code](https://code.visualstudio.com/) with the [Remote - Containers extension](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers) where they will be installed automatically. 38 | 39 | - [Azure Developer CLI](https://aka.ms/azd-install) 40 | - [Python (3.10+)](https://www.python.org/downloads/) - for the API backend 41 | - [Node.js with npm (18.17.1+)](https://nodejs.org/) - for the Web frontend 42 | 43 | ### Quickstart 44 | To learn how to get started with any template, follow the steps in [this quickstart](https://learn.microsoft.com/azure/developer/azure-developer-cli/get-started?tabs=localinstall&pivots=programming-language-python) with this template(`Azure-Samples/todo-python-mongo`). 45 | 46 | This quickstart will show you how to authenticate on Azure, initialize using a template, provision infrastructure and deploy code on Azure via the following commands: 47 | 48 | ```bash 49 | # Log in to azd. Only required once per-install. 50 | azd auth login 51 | 52 | # First-time project setup. Initialize a project in the current directory, using this template. 53 | azd init --template Azure-Samples/todo-python-mongo 54 | 55 | # Provision and deploy to Azure 56 | azd up 57 | ``` 58 | 59 | ### Application Architecture 60 | 61 | This application utilizes the following Azure resources: 62 | 63 | - [**Azure App Services**](https://docs.microsoft.com/azure/app-service/) to host the Web frontend and API backend 64 | - [**Azure Cosmos DB API for MongoDB**](https://docs.microsoft.com/azure/cosmos-db/mongodb/mongodb-introduction) for storage 65 | - [**Azure Monitor**](https://docs.microsoft.com/azure/azure-monitor/) for monitoring and logging 66 | - [**Azure Key Vault**](https://docs.microsoft.com/azure/key-vault/) for securing secrets 67 | 68 | Here's a high level architecture diagram that illustrates these components. Notice that these are all contained within a single [resource group](https://docs.microsoft.com/azure/azure-resource-manager/management/manage-resource-groups-portal), that will be created for you when you create the resources. 69 | 70 | !["Application architecture diagram"](assets/resources.png) 71 | 72 | > This template provisions resources to an Azure subscription that you will select upon provisioning them. Please refer to the [Pricing calculator for Microsoft Azure](https://azure.microsoft.com/pricing/calculator/) and, if needed, update the included Azure resource definitions found in `infra/main.bicep` to suit your needs. 73 | 74 | ### Application Code 75 | 76 | This template is structured to follow the [Azure Developer CLI](https://aka.ms/azure-dev/overview). You can learn more about `azd` architecture in [the official documentation](https://learn.microsoft.com/azure/developer/azure-developer-cli/make-azd-compatible?pivots=azd-create#understand-the-azd-architecture). 77 | 78 | ### Next Steps 79 | 80 | At this point, you have a complete application deployed on Azure. But there is much more that the Azure Developer CLI can do. These next steps will introduce you to additional commands that will make creating applications on Azure much easier. Using the Azure Developer CLI, you can setup your pipelines, monitor your application, test and debug locally. 81 | 82 | > Note: Needs to manually install [setup-azd extension](https://marketplace.visualstudio.com/items?itemName=ms-azuretools.azd) for Azure DevOps (azdo). 83 | 84 | - [`azd pipeline config`](https://learn.microsoft.com/azure/developer/azure-developer-cli/configure-devops-pipeline?tabs=GitHub) - to configure a CI/CD pipeline (using GitHub Actions or Azure DevOps) to deploy your application whenever code is pushed to the main branch. 85 | 86 | - [`azd monitor`](https://learn.microsoft.com/azure/developer/azure-developer-cli/monitor-your-app) - to monitor the application and quickly navigate to the various Application Insights dashboards (e.g. overview, live metrics, logs) 87 | 88 | - [Run and Debug Locally](https://learn.microsoft.com/azure/developer/azure-developer-cli/debug?pivots=ide-vs-code) - using Visual Studio Code and the Azure Developer CLI extension 89 | 90 | - [`azd down`](https://learn.microsoft.com/azure/developer/azure-developer-cli/reference#azd-down) - to delete all the Azure resources created with this template 91 | 92 | - [Enable optional features, like APIM](./OPTIONAL_FEATURES.md) - for enhanced backend API protection and observability 93 | 94 | ### Additional `azd` commands 95 | 96 | The Azure Developer CLI includes many other commands to help with your Azure development experience. You can view these commands at the terminal by running `azd help`. You can also view the full list of commands on our [Azure Developer CLI command](https://aka.ms/azure-dev/ref) page. 97 | 98 | ## Security 99 | 100 | ### Roles 101 | 102 | This template creates a [managed identity](https://docs.microsoft.com/azure/active-directory/managed-identities-azure-resources/overview) for your app inside your Azure Active Directory tenant, and it is used to authenticate your app with Azure and other services that support Azure AD authentication like Key Vault via access policies. You will see principalId referenced in the infrastructure as code files, that refers to the id of the currently logged in Azure Developer CLI user, which will be granted access policies and permissions to run the application locally. To view your managed identity in the Azure Portal, follow these [steps](https://docs.microsoft.com/azure/active-directory/managed-identities-azure-resources/how-to-view-managed-identity-service-principal-portal). 103 | 104 | ### Key Vault 105 | 106 | This template uses [Azure Key Vault](https://docs.microsoft.com/azure/key-vault/general/overview) to securely store your Cosmos DB connection string for the provisioned Cosmos DB account. Key Vault is a cloud service for securely storing and accessing secrets (API keys, passwords, certificates, cryptographic keys) and makes it simple to give other Azure services access to them. As you continue developing your solution, you may add as many secrets to your Key Vault as you require. 107 | 108 | ## Reporting Issues and Feedback 109 | 110 | If you have any feature requests, issues, or areas for improvement, please [file an issue](https://aka.ms/azure-dev/issues). To keep up-to-date, ask questions, or share suggestions, join our [GitHub Discussions](https://aka.ms/azure-dev/discussions). You may also contact us via AzDevTeam@microsoft.com. 111 | -------------------------------------------------------------------------------- /assets/resources-with-apim.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Azure-Samples/todo-python-mongo/58f5d327e15c8748c8a6f3172066dfa70e1efc37/assets/resources-with-apim.png -------------------------------------------------------------------------------- /assets/resources.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Azure-Samples/todo-python-mongo/58f5d327e15c8748c8a6f3172066dfa70e1efc37/assets/resources.png -------------------------------------------------------------------------------- /assets/urls.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Azure-Samples/todo-python-mongo/58f5d327e15c8748c8a6f3172066dfa70e1efc37/assets/urls.png -------------------------------------------------------------------------------- /assets/web.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Azure-Samples/todo-python-mongo/58f5d327e15c8748c8a6f3172066dfa70e1efc37/assets/web.png -------------------------------------------------------------------------------- /azure.yaml: -------------------------------------------------------------------------------- 1 | # yaml-language-server: $schema=https://raw.githubusercontent.com/Azure/azure-dev/main/schemas/v1.0/azure.yaml.json 2 | 3 | name: todo-python-mongo 4 | metadata: 5 | template: todo-python-mongo@0.0.1-beta 6 | workflows: 7 | up: 8 | steps: 9 | - azd: provision 10 | - azd: deploy --all 11 | services: 12 | web: 13 | project: ./src/web 14 | dist: dist 15 | language: js 16 | host: appservice 17 | hooks: 18 | # Creates a temporary `.env.local` file for the build command. Vite will automatically use it during build. 19 | # The expected/required values are mapped to the infrastructure outputs. 20 | # .env.local is ignored by git, so it will not be committed if, for any reason, if deployment fails. 21 | # see: https://vitejs.dev/guide/env-and-mode 22 | # Note: Notice that dotenv must be a project dependency for this to work. See package.json. 23 | prepackage: 24 | windows: 25 | shell: pwsh 26 | run: 'echo "VITE_API_BASE_URL=""$env:API_BASE_URL""" > .env.local ; echo "VITE_APPLICATIONINSIGHTS_CONNECTION_STRING=""$env:APPLICATIONINSIGHTS_CONNECTION_STRING""" >> .env.local' 27 | posix: 28 | shell: sh 29 | run: 'echo VITE_API_BASE_URL=\"$API_BASE_URL\" > .env.local && echo VITE_APPLICATIONINSIGHTS_CONNECTION_STRING=\"$APPLICATIONINSIGHTS_CONNECTION_STRING\" >> .env.local' 30 | postdeploy: 31 | windows: 32 | shell: pwsh 33 | run: 'rm .env.local' 34 | posix: 35 | shell: sh 36 | run: 'rm .env.local' 37 | api: 38 | project: ./src/api 39 | language: py 40 | host: appservice 41 | -------------------------------------------------------------------------------- /infra/abbreviations.json: -------------------------------------------------------------------------------- 1 | { 2 | "analysisServicesServers": "as", 3 | "apiManagementService": "apim-", 4 | "appConfigurationStores": "appcs-", 5 | "appManagedEnvironments": "cae-", 6 | "appContainerApps": "ca-", 7 | "authorizationPolicyDefinitions": "policy-", 8 | "automationAutomationAccounts": "aa-", 9 | "blueprintBlueprints": "bp-", 10 | "blueprintBlueprintsArtifacts": "bpa-", 11 | "cacheRedis": "redis-", 12 | "cdnProfiles": "cdnp-", 13 | "cdnProfilesEndpoints": "cdne-", 14 | "cognitiveServicesAccounts": "cog-", 15 | "cognitiveServicesFormRecognizer": "cog-fr-", 16 | "cognitiveServicesTextAnalytics": "cog-ta-", 17 | "cognitiveServicesSpeech": "cog-sp-", 18 | "computeAvailabilitySets": "avail-", 19 | "computeCloudServices": "cld-", 20 | "computeDiskEncryptionSets": "des", 21 | "computeDisks": "disk", 22 | "computeDisksOs": "osdisk", 23 | "computeGalleries": "gal", 24 | "computeSnapshots": "snap-", 25 | "computeVirtualMachines": "vm", 26 | "computeVirtualMachineScaleSets": "vmss-", 27 | "containerInstanceContainerGroups": "ci", 28 | "containerRegistryRegistries": "cr", 29 | "containerServiceManagedClusters": "aks-", 30 | "databricksWorkspaces": "dbw-", 31 | "dataFactoryFactories": "adf-", 32 | "dataLakeAnalyticsAccounts": "dla", 33 | "dataLakeStoreAccounts": "dls", 34 | "dataMigrationServices": "dms-", 35 | "dBforMySQLServers": "mysql-", 36 | "dBforPostgreSQLServers": "psql-", 37 | "devicesIotHubs": "iot-", 38 | "devicesProvisioningServices": "provs-", 39 | "devicesProvisioningServicesCertificates": "pcert-", 40 | "documentDBDatabaseAccounts": "cosmos-", 41 | "eventGridDomains": "evgd-", 42 | "eventGridDomainsTopics": "evgt-", 43 | "eventGridEventSubscriptions": "evgs-", 44 | "eventHubNamespaces": "evhns-", 45 | "eventHubNamespacesEventHubs": "evh-", 46 | "hdInsightClustersHadoop": "hadoop-", 47 | "hdInsightClustersHbase": "hbase-", 48 | "hdInsightClustersKafka": "kafka-", 49 | "hdInsightClustersMl": "mls-", 50 | "hdInsightClustersSpark": "spark-", 51 | "hdInsightClustersStorm": "storm-", 52 | "hybridComputeMachines": "arcs-", 53 | "insightsActionGroups": "ag-", 54 | "insightsComponents": "appi-", 55 | "keyVaultVaults": "kv-", 56 | "kubernetesConnectedClusters": "arck", 57 | "kustoClusters": "dec", 58 | "kustoClustersDatabases": "dedb", 59 | "loadTesting": "lt-", 60 | "logicIntegrationAccounts": "ia-", 61 | "logicWorkflows": "logic-", 62 | "machineLearningServicesWorkspaces": "mlw-", 63 | "managedIdentityUserAssignedIdentities": "id-", 64 | "managementManagementGroups": "mg-", 65 | "migrateAssessmentProjects": "migr-", 66 | "networkApplicationGateways": "agw-", 67 | "networkApplicationSecurityGroups": "asg-", 68 | "networkAzureFirewalls": "afw-", 69 | "networkBastionHosts": "bas-", 70 | "networkConnections": "con-", 71 | "networkDnsZones": "dnsz-", 72 | "networkExpressRouteCircuits": "erc-", 73 | "networkFirewallPolicies": "afwp-", 74 | "networkFirewallPoliciesWebApplication": "waf", 75 | "networkFirewallPoliciesRuleGroups": "wafrg", 76 | "networkFrontDoors": "fd-", 77 | "networkFrontdoorWebApplicationFirewallPolicies": "fdfp-", 78 | "networkLoadBalancersExternal": "lbe-", 79 | "networkLoadBalancersInternal": "lbi-", 80 | "networkLoadBalancersInboundNatRules": "rule-", 81 | "networkLocalNetworkGateways": "lgw-", 82 | "networkNatGateways": "ng-", 83 | "networkNetworkInterfaces": "nic-", 84 | "networkNetworkSecurityGroups": "nsg-", 85 | "networkNetworkSecurityGroupsSecurityRules": "nsgsr-", 86 | "networkNetworkWatchers": "nw-", 87 | "networkPrivateDnsZones": "pdnsz-", 88 | "networkPrivateLinkServices": "pl-", 89 | "networkPublicIPAddresses": "pip-", 90 | "networkPublicIPPrefixes": "ippre-", 91 | "networkRouteFilters": "rf-", 92 | "networkRouteTables": "rt-", 93 | "networkRouteTablesRoutes": "udr-", 94 | "networkTrafficManagerProfiles": "traf-", 95 | "networkVirtualNetworkGateways": "vgw-", 96 | "networkVirtualNetworks": "vnet-", 97 | "networkVirtualNetworksSubnets": "snet-", 98 | "networkVirtualNetworksVirtualNetworkPeerings": "peer-", 99 | "networkVirtualWans": "vwan-", 100 | "networkVpnGateways": "vpng-", 101 | "networkVpnGatewaysVpnConnections": "vcn-", 102 | "networkVpnGatewaysVpnSites": "vst-", 103 | "notificationHubsNamespaces": "ntfns-", 104 | "notificationHubsNamespacesNotificationHubs": "ntf-", 105 | "operationalInsightsWorkspaces": "log-", 106 | "portalDashboards": "dash-", 107 | "powerBIDedicatedCapacities": "pbi-", 108 | "purviewAccounts": "pview-", 109 | "recoveryServicesVaults": "rsv-", 110 | "resourcesResourceGroups": "rg-", 111 | "searchSearchServices": "srch-", 112 | "serviceBusNamespaces": "sb-", 113 | "serviceBusNamespacesQueues": "sbq-", 114 | "serviceBusNamespacesTopics": "sbt-", 115 | "serviceEndPointPolicies": "se-", 116 | "serviceFabricClusters": "sf-", 117 | "signalRServiceSignalR": "sigr", 118 | "sqlManagedInstances": "sqlmi-", 119 | "sqlServers": "sql-", 120 | "sqlServersDataWarehouse": "sqldw-", 121 | "sqlServersDatabases": "sqldb-", 122 | "sqlServersDatabasesStretch": "sqlstrdb-", 123 | "storageStorageAccounts": "st", 124 | "storageStorageAccountsVm": "stvm", 125 | "storSimpleManagers": "ssimp", 126 | "streamAnalyticsCluster": "asa-", 127 | "synapseWorkspaces": "syn", 128 | "synapseWorkspacesAnalyticsWorkspaces": "synw", 129 | "synapseWorkspacesSqlPoolsDedicated": "syndp", 130 | "synapseWorkspacesSqlPoolsSpark": "synsp", 131 | "timeSeriesInsightsEnvironments": "tsi-", 132 | "webServerFarms": "plan-", 133 | "webSitesAppService": "app-", 134 | "webSitesAppServiceEnvironment": "ase-", 135 | "webSitesFunctions": "func-", 136 | "webStaticSites": "stapp-" 137 | } 138 | -------------------------------------------------------------------------------- /infra/app/api-appservice-avm.bicep: -------------------------------------------------------------------------------- 1 | param name string 2 | param location string = resourceGroup().location 3 | param tags object = {} 4 | 5 | param allowedOrigins array = [] 6 | param appCommandLine string? 7 | param appInsightResourceId string 8 | param appServicePlanId string 9 | @secure() 10 | param appSettings object = {} 11 | param siteConfig object = {} 12 | param serviceName string = 'api' 13 | 14 | @description('Required. Type of site to deploy.') 15 | param kind string 16 | 17 | @description('Optional. If client affinity is enabled.') 18 | param clientAffinityEnabled bool = true 19 | 20 | @description('Optional. Required if app of kind functionapp. Resource ID of the storage account to manage triggers and logging function executions.') 21 | param storageAccountResourceId string? 22 | 23 | module api 'br/public:avm/res/web/site:0.6.0' = { 24 | name: '${name}-app-module' 25 | params: { 26 | kind: kind 27 | name: name 28 | serverFarmResourceId: appServicePlanId 29 | tags: union(tags, { 'azd-service-name': serviceName }) 30 | location: location 31 | appInsightResourceId: appInsightResourceId 32 | clientAffinityEnabled: clientAffinityEnabled 33 | storageAccountResourceId: storageAccountResourceId 34 | managedIdentities: { 35 | systemAssigned: true 36 | } 37 | siteConfig: union(siteConfig, { 38 | cors: { 39 | allowedOrigins: union(['https://portal.azure.com', 'https://ms.portal.azure.com'], allowedOrigins) 40 | } 41 | appCommandLine: appCommandLine 42 | }) 43 | appSettingsKeyValuePairs: union( 44 | appSettings, 45 | { ENABLE_ORYX_BUILD: true, ApplicationInsightsAgent_EXTENSION_VERSION: contains(kind, 'linux') ? '~3' : '~2' } 46 | ) 47 | logsConfiguration: { 48 | applicationLogs: { fileSystem: { level: 'Verbose' } } 49 | detailedErrorMessages: { enabled: true } 50 | failedRequestsTracing: { enabled: true } 51 | httpLogs: { fileSystem: { enabled: true, retentionInDays: 1, retentionInMb: 35 } } 52 | } 53 | } 54 | } 55 | 56 | output SERVICE_API_IDENTITY_PRINCIPAL_ID string = api.outputs.systemAssignedMIPrincipalId 57 | output SERVICE_API_NAME string = api.outputs.name 58 | output SERVICE_API_URI string = 'https://${api.outputs.defaultHostname}' 59 | -------------------------------------------------------------------------------- /infra/app/db-avm.bicep: -------------------------------------------------------------------------------- 1 | param accountName string 2 | param location string = resourceGroup().location 3 | param tags object = {} 4 | param cosmosDatabaseName string = '' 5 | param keyVaultResourceId string 6 | param connectionStringKey string = 'AZURE-COSMOS-CONNECTION-STRING' 7 | param collections array = [ 8 | { 9 | name: 'TodoList' 10 | id: 'TodoList' 11 | shardKey: { 12 | keys: [ 13 | 'Hash' 14 | ] 15 | } 16 | indexes: [ 17 | { 18 | key: { 19 | keys: [ 20 | '_id' 21 | ] 22 | } 23 | } 24 | ] 25 | } 26 | { 27 | name: 'TodoItem' 28 | id: 'TodoItem' 29 | shardKey: { 30 | keys: [ 31 | 'Hash' 32 | ] 33 | } 34 | indexes: [ 35 | { 36 | key: { 37 | keys: [ 38 | '_id' 39 | ] 40 | } 41 | } 42 | ] 43 | } 44 | ] 45 | 46 | var defaultDatabaseName = 'Todo' 47 | var actualDatabaseName = !empty(cosmosDatabaseName) ? cosmosDatabaseName : defaultDatabaseName 48 | 49 | module cosmos 'br/public:avm/res/document-db/database-account:0.6.0' = { 50 | name: 'cosmos-mongo' 51 | params: { 52 | locations: [ 53 | { 54 | failoverPriority: 0 55 | isZoneRedundant: false 56 | locationName: location 57 | } 58 | ] 59 | name: accountName 60 | location: location 61 | mongodbDatabases: [ 62 | { 63 | name: actualDatabaseName 64 | tags: tags 65 | collections: collections 66 | } 67 | ] 68 | secretsExportConfiguration: { 69 | keyVaultResourceId: keyVaultResourceId 70 | primaryWriteConnectionStringSecretName: connectionStringKey 71 | } 72 | } 73 | } 74 | 75 | output connectionStringKey string = connectionStringKey 76 | output databaseName string = actualDatabaseName 77 | output endpoint string = cosmos.outputs.endpoint 78 | -------------------------------------------------------------------------------- /infra/app/web-appservice-avm.bicep: -------------------------------------------------------------------------------- 1 | param name string 2 | param location string = resourceGroup().location 3 | param tags object = {} 4 | param serviceName string = 'web' 5 | param appCommandLine string = 'pm2 serve /home/site/wwwroot --no-daemon --spa' 6 | param appInsightResourceId string 7 | param appServicePlanId string 8 | param linuxFxVersion string 9 | param kind string = 'app,linux' 10 | 11 | module web 'br/public:avm/res/web/site:0.6.0' = { 12 | name: '${name}-deployment' 13 | params: { 14 | kind: kind 15 | name: name 16 | serverFarmResourceId: appServicePlanId 17 | tags: union(tags, { 'azd-service-name': serviceName }) 18 | location: location 19 | appInsightResourceId: appInsightResourceId 20 | siteConfig: { 21 | appCommandLine: appCommandLine 22 | linuxFxVersion: linuxFxVersion 23 | alwaysOn: true 24 | } 25 | logsConfiguration: { 26 | applicationLogs: { fileSystem: { level: 'Verbose' } } 27 | detailedErrorMessages: { enabled: true } 28 | failedRequestsTracing: { enabled: true } 29 | httpLogs: { fileSystem: { enabled: true, retentionInDays: 1, retentionInMb: 35 } } 30 | } 31 | appSettingsKeyValuePairs: { ApplicationInsightsAgent_EXTENSION_VERSION: contains(kind, 'linux') ? '~3' : '~2' } 32 | } 33 | } 34 | 35 | output SERVICE_WEB_IDENTITY_PRINCIPAL_ID string = web.outputs.systemAssignedMIPrincipalId 36 | output SERVICE_WEB_NAME string = web.outputs.name 37 | output SERVICE_WEB_URI string = 'https://${web.outputs.defaultHostname}' 38 | -------------------------------------------------------------------------------- /infra/main.bicep: -------------------------------------------------------------------------------- 1 | targetScope = 'subscription' 2 | 3 | @minLength(1) 4 | @maxLength(64) 5 | @description('Name of the the environment which is used to generate a short unique hash used in all resources.') 6 | param environmentName string 7 | 8 | @minLength(1) 9 | @description('Primary location for all resources') 10 | param location string 11 | 12 | // Optional parameters to override the default azd resource naming conventions. Update the main.parameters.json file to provide values. e.g.,: 13 | // "resourceGroupName": { 14 | // "value": "myGroupName" 15 | // } 16 | param apiServiceName string = '' 17 | param applicationInsightsDashboardName string = '' 18 | param applicationInsightsName string = '' 19 | param appServicePlanName string = '' 20 | param cosmosAccountName string = '' 21 | param keyVaultName string = '' 22 | param logAnalyticsName string = '' 23 | param resourceGroupName string = '' 24 | param webServiceName string = '' 25 | param apimServiceName string = '' 26 | 27 | @description('Flag to use Azure API Management to mediate the calls between the Web frontend and the backend API') 28 | param useAPIM bool = false 29 | 30 | @description('API Management SKU to use if APIM is enabled') 31 | param apimSku string = 'Consumption' 32 | 33 | @description('Id of the user or app to assign application roles') 34 | param principalId string = '' 35 | 36 | var abbrs = loadJsonContent('./abbreviations.json') 37 | var resourceToken = toLower(uniqueString(subscription().id, environmentName, location)) 38 | var tags = { 'azd-env-name': environmentName } 39 | 40 | // Organize resources in a resource group 41 | resource rg 'Microsoft.Resources/resourceGroups@2021-04-01' = { 42 | name: !empty(resourceGroupName) ? resourceGroupName : '${abbrs.resourcesResourceGroups}${environmentName}' 43 | location: location 44 | tags: tags 45 | } 46 | 47 | // The application frontend 48 | module web './app/web-appservice-avm.bicep' = { 49 | name: 'web' 50 | scope: rg 51 | params: { 52 | name: !empty(webServiceName) ? webServiceName : '${abbrs.webSitesAppService}web-${resourceToken}' 53 | location: location 54 | tags: tags 55 | appServicePlanId: appServicePlan.outputs.resourceId 56 | appInsightResourceId: monitoring.outputs.applicationInsightsResourceId 57 | linuxFxVersion: 'node|20-lts' 58 | } 59 | } 60 | 61 | // The application backend 62 | module api './app/api-appservice-avm.bicep' = { 63 | name: 'api' 64 | scope: rg 65 | params: { 66 | name: !empty(apiServiceName) ? apiServiceName : '${abbrs.webSitesAppService}api-${resourceToken}' 67 | location: location 68 | tags: tags 69 | kind: 'app' 70 | appServicePlanId: appServicePlan.outputs.resourceId 71 | siteConfig: { 72 | alwaysOn: true 73 | linuxFxVersion: 'python|3.10' 74 | appCommandLine: 'gunicorn --workers 4 --threads 2 --timeout 60 --access-logfile "-" --error-logfile "-" --bind=0.0.0.0:8000 -k uvicorn.workers.UvicornWorker todo.app:app' 75 | } 76 | appSettings: { 77 | AZURE_KEY_VAULT_ENDPOINT: keyVault.outputs.uri 78 | AZURE_COSMOS_CONNECTION_STRING_KEY: cosmos.outputs.connectionStringKey 79 | AZURE_COSMOS_DATABASE_NAME: cosmos.outputs.databaseName 80 | AZURE_COSMOS_ENDPOINT: 'https://${cosmos.outputs.databaseName}.documents.azure.com:443/' 81 | API_ALLOW_ORIGINS: web.outputs.SERVICE_WEB_URI 82 | SCM_DO_BUILD_DURING_DEPLOYMENT: true 83 | } 84 | appInsightResourceId: monitoring.outputs.applicationInsightsResourceId 85 | allowedOrigins: [ web.outputs.SERVICE_WEB_URI ] 86 | } 87 | } 88 | 89 | // Give the API access to KeyVault 90 | module accessKeyVault 'br/public:avm/res/key-vault/vault:0.3.5' = { 91 | name: 'accesskeyvault' 92 | scope: rg 93 | params: { 94 | name: keyVault.outputs.name 95 | enableRbacAuthorization: false 96 | enableVaultForDeployment: false 97 | enableVaultForTemplateDeployment: false 98 | enablePurgeProtection: false 99 | sku: 'standard' 100 | accessPolicies: [ 101 | { 102 | objectId: principalId 103 | permissions: { 104 | secrets: [ 'get', 'list' ] 105 | } 106 | } 107 | { 108 | objectId: api.outputs.SERVICE_API_IDENTITY_PRINCIPAL_ID 109 | permissions: { 110 | secrets: [ 'get', 'list' ] 111 | } 112 | } 113 | ] 114 | } 115 | } 116 | 117 | // The application database 118 | module cosmos './app/db-avm.bicep' = { 119 | name: 'cosmos' 120 | scope: rg 121 | params: { 122 | accountName: !empty(cosmosAccountName) ? cosmosAccountName : '${abbrs.documentDBDatabaseAccounts}${resourceToken}' 123 | location: location 124 | tags: tags 125 | keyVaultResourceId: keyVault.outputs.resourceId 126 | } 127 | } 128 | 129 | // Create an App Service Plan to group applications under the same payment plan and SKU 130 | module appServicePlan 'br/public:avm/res/web/serverfarm:0.1.0' = { 131 | name: 'appserviceplan' 132 | scope: rg 133 | params: { 134 | name: !empty(appServicePlanName) ? appServicePlanName : '${abbrs.webServerFarms}${resourceToken}' 135 | sku: { 136 | name: 'B3' 137 | tier: 'Basic' 138 | } 139 | location: location 140 | tags: tags 141 | reserved: true 142 | kind: 'Linux' 143 | } 144 | } 145 | 146 | // Create a keyvault to store secrets 147 | module keyVault 'br/public:avm/res/key-vault/vault:0.3.5' = { 148 | name: 'keyvault' 149 | scope: rg 150 | params: { 151 | name: !empty(keyVaultName) ? keyVaultName : '${abbrs.keyVaultVaults}${resourceToken}' 152 | location: location 153 | tags: tags 154 | enableRbacAuthorization: false 155 | enableVaultForDeployment: false 156 | enableVaultForTemplateDeployment: false 157 | enablePurgeProtection: false 158 | sku: 'standard' 159 | } 160 | } 161 | 162 | /// Monitor application with Azure Monitor 163 | module monitoring 'br/public:avm/ptn/azd/monitoring:0.1.0' = { 164 | name: 'monitoring' 165 | scope: rg 166 | params: { 167 | applicationInsightsName: !empty(applicationInsightsName) ? applicationInsightsName : '${abbrs.insightsComponents}${resourceToken}' 168 | logAnalyticsName: !empty(logAnalyticsName) ? logAnalyticsName : '${abbrs.operationalInsightsWorkspaces}${resourceToken}' 169 | applicationInsightsDashboardName: !empty(applicationInsightsDashboardName) ? applicationInsightsDashboardName : '${abbrs.portalDashboards}${resourceToken}' 170 | location: location 171 | tags: tags 172 | } 173 | } 174 | 175 | // Creates Azure API Management (APIM) service to mediate the requests between the frontend and the backend API 176 | module apim 'br/public:avm/res/api-management/service:0.2.0' = if (useAPIM) { 177 | name: 'apim-deployment' 178 | scope: rg 179 | params: { 180 | name: !empty(apimServiceName) ? apimServiceName : '${abbrs.apiManagementService}${resourceToken}' 181 | publisherEmail: 'noreply@microsoft.com' 182 | publisherName: 'n/a' 183 | location: location 184 | tags: tags 185 | sku: apimSku 186 | skuCount: 0 187 | zones: [] 188 | customProperties: {} 189 | loggers: [ 190 | { 191 | name: 'app-insights-logger' 192 | credentials: { 193 | instrumentationKey: monitoring.outputs.applicationInsightsInstrumentationKey 194 | } 195 | loggerDescription: 'Logger to Azure Application Insights' 196 | isBuffered: false 197 | loggerType: 'applicationInsights' 198 | targetResourceId: monitoring.outputs.applicationInsightsResourceId 199 | } 200 | ] 201 | } 202 | } 203 | 204 | //Configures the API settings for an api app within the Azure API Management (APIM) service. 205 | module apimApi 'br/public:avm/ptn/azd/apim-api:0.1.0' = if (useAPIM) { 206 | name: 'apim-api-deployment' 207 | scope: rg 208 | params: { 209 | apiBackendUrl: api.outputs.SERVICE_API_URI 210 | apiDescription: 'This is a simple Todo API' 211 | apiDisplayName: 'Simple Todo API' 212 | apiName: 'todo-api' 213 | apiPath: 'todo' 214 | name: useAPIM ? apim.outputs.name : '' 215 | webFrontendUrl: web.outputs.SERVICE_WEB_URI 216 | location: location 217 | apiAppName: api.outputs.SERVICE_API_NAME 218 | } 219 | } 220 | 221 | // Data outputs 222 | output AZURE_COSMOS_CONNECTION_STRING_KEY string = cosmos.outputs.connectionStringKey 223 | output AZURE_COSMOS_DATABASE_NAME string = cosmos.outputs.databaseName 224 | 225 | // App outputs 226 | output APPLICATIONINSIGHTS_CONNECTION_STRING string = monitoring.outputs.applicationInsightsConnectionString 227 | output AZURE_KEY_VAULT_ENDPOINT string = keyVault.outputs.uri 228 | output AZURE_KEY_VAULT_NAME string = keyVault.outputs.name 229 | output AZURE_LOCATION string = location 230 | output AZURE_TENANT_ID string = tenant().tenantId 231 | output API_BASE_URL string = useAPIM ? apimApi.outputs.serviceApiUri : api.outputs.SERVICE_API_URI 232 | output REACT_APP_WEB_BASE_URL string = web.outputs.SERVICE_WEB_URI 233 | output USE_APIM bool = useAPIM 234 | output SERVICE_API_ENDPOINTS array = useAPIM ? [ apimApi.outputs.serviceApiUri, api.outputs.SERVICE_API_URI ]: [] 235 | -------------------------------------------------------------------------------- /infra/main.parameters.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#", 3 | "contentVersion": "1.0.0.0", 4 | "parameters": { 5 | "environmentName": { 6 | "value": "${AZURE_ENV_NAME}" 7 | }, 8 | "location": { 9 | "value": "${AZURE_LOCATION}" 10 | }, 11 | "principalId": { 12 | "value": "${AZURE_PRINCIPAL_ID}" 13 | }, 14 | "useAPIM": { 15 | "value": "${USE_APIM=false}" 16 | }, 17 | "apimSku": { 18 | "value": "${APIM_SKU=Consumption}" 19 | } 20 | } 21 | } -------------------------------------------------------------------------------- /openapi.yaml: -------------------------------------------------------------------------------- 1 | openapi: 3.0.0 2 | info: 3 | description: Simple Todo API 4 | version: 3.0.0 5 | title: Simple Todo API 6 | contact: 7 | email: azdevteam@microsoft.com 8 | 9 | components: 10 | schemas: 11 | TodoItem: 12 | type: object 13 | required: 14 | - listId 15 | - name 16 | - description 17 | description: A task that needs to be completed 18 | properties: 19 | id: 20 | type: string 21 | listId: 22 | type: string 23 | name: 24 | type: string 25 | description: 26 | type: string 27 | state: 28 | $ref: "#/components/schemas/TodoState" 29 | dueDate: 30 | type: string 31 | format: date-time 32 | completedDate: 33 | type: string 34 | format: date-time 35 | TodoList: 36 | type: object 37 | required: 38 | - name 39 | properties: 40 | id: 41 | type: string 42 | name: 43 | type: string 44 | description: 45 | type: string 46 | description: " A list of related Todo items" 47 | TodoState: 48 | type: string 49 | enum: 50 | - todo 51 | - inprogress 52 | - done 53 | parameters: 54 | listId: 55 | in: path 56 | required: true 57 | name: listId 58 | description: The Todo list unique identifier 59 | schema: 60 | type: string 61 | itemId: 62 | in: path 63 | required: true 64 | name: itemId 65 | description: The Todo item unique identifier 66 | schema: 67 | type: string 68 | state: 69 | in: path 70 | required: true 71 | name: state 72 | description: The Todo item state 73 | schema: 74 | $ref: "#/components/schemas/TodoState" 75 | top: 76 | in: query 77 | required: false 78 | name: top 79 | description: The max number of items to returns in a result 80 | schema: 81 | type: number 82 | default: 20 83 | skip: 84 | in: query 85 | required: false 86 | name: skip 87 | description: The number of items to skip within the results 88 | schema: 89 | type: number 90 | default: 0 91 | 92 | requestBodies: 93 | TodoList: 94 | description: The Todo List 95 | content: 96 | application/json: 97 | schema: 98 | $ref: "#/components/schemas/TodoList" 99 | TodoItem: 100 | description: The Todo Item 101 | content: 102 | application/json: 103 | schema: 104 | $ref: "#/components/schemas/TodoItem" 105 | 106 | responses: 107 | TodoList: 108 | description: A Todo list result 109 | content: 110 | application/json: 111 | schema: 112 | $ref: "#/components/schemas/TodoList" 113 | TodoListArray: 114 | description: An array of Todo lists 115 | content: 116 | application/json: 117 | schema: 118 | type: array 119 | items: 120 | $ref: "#/components/schemas/TodoList" 121 | TodoItem: 122 | description: A Todo item result 123 | content: 124 | application/json: 125 | schema: 126 | $ref: "#/components/schemas/TodoItem" 127 | TodoItemArray: 128 | description: An array of Todo items 129 | content: 130 | application/json: 131 | schema: 132 | type: array 133 | items: 134 | $ref: "#/components/schemas/TodoItem" 135 | 136 | paths: 137 | /lists: 138 | get: 139 | operationId: GetLists 140 | summary: Gets an array of Todo lists 141 | tags: 142 | - Lists 143 | parameters: 144 | - $ref: "#/components/parameters/top" 145 | - $ref: "#/components/parameters/skip" 146 | responses: 147 | 200: 148 | $ref: "#/components/responses/TodoListArray" 149 | post: 150 | operationId: CreateList 151 | summary: Creates a new Todo list 152 | tags: 153 | - Lists 154 | requestBody: 155 | $ref: "#/components/requestBodies/TodoList" 156 | responses: 157 | 201: 158 | $ref: "#/components/responses/TodoList" 159 | 400: 160 | description: Invalid request schema 161 | /lists/{listId}: 162 | get: 163 | operationId: GetListById 164 | summary: Gets a Todo list by unique identifier 165 | tags: 166 | - Lists 167 | parameters: 168 | - $ref: "#/components/parameters/listId" 169 | responses: 170 | 200: 171 | $ref: "#/components/responses/TodoList" 172 | 404: 173 | description: Todo list not found 174 | put: 175 | operationId: UpdateListById 176 | summary: Updates a Todo list by unique identifier 177 | tags: 178 | - Lists 179 | requestBody: 180 | $ref: "#/components/requestBodies/TodoList" 181 | parameters: 182 | - $ref: "#/components/parameters/listId" 183 | responses: 184 | 200: 185 | $ref: "#/components/responses/TodoList" 186 | 404: 187 | description: Todo list not found 188 | 400: 189 | description: Todo list is invalid 190 | delete: 191 | operationId: DeleteListById 192 | summary: Deletes a Todo list by unique identifier 193 | tags: 194 | - Lists 195 | parameters: 196 | - $ref: "#/components/parameters/listId" 197 | responses: 198 | 204: 199 | description: Todo list deleted successfully 200 | 404: 201 | description: Todo list not found 202 | /lists/{listId}/items: 203 | post: 204 | operationId: CreateItem 205 | summary: Creates a new Todo item within a list 206 | tags: 207 | - Items 208 | requestBody: 209 | $ref: "#/components/requestBodies/TodoItem" 210 | parameters: 211 | - $ref: "#/components/parameters/listId" 212 | responses: 213 | 201: 214 | $ref: "#/components/responses/TodoItem" 215 | 404: 216 | description: Todo list not found 217 | get: 218 | operationId: GetItemsByListId 219 | summary: Gets Todo items within the specified list 220 | tags: 221 | - Items 222 | parameters: 223 | - $ref: "#/components/parameters/listId" 224 | - $ref: "#/components/parameters/top" 225 | - $ref: "#/components/parameters/skip" 226 | responses: 227 | 200: 228 | $ref: "#/components/responses/TodoItemArray" 229 | 404: 230 | description: Todo list not found 231 | /lists/{listId}/items/{itemId}: 232 | get: 233 | operationId: GetItemById 234 | summary: Gets a Todo item by unique identifier 235 | tags: 236 | - Items 237 | parameters: 238 | - $ref: "#/components/parameters/listId" 239 | - $ref: "#/components/parameters/itemId" 240 | responses: 241 | 200: 242 | $ref: "#/components/responses/TodoItem" 243 | 404: 244 | description: Todo list or item not found 245 | put: 246 | operationId: UpdateItemById 247 | summary: Updates a Todo item by unique identifier 248 | tags: 249 | - Items 250 | requestBody: 251 | $ref: "#/components/requestBodies/TodoItem" 252 | parameters: 253 | - $ref: "#/components/parameters/listId" 254 | - $ref: "#/components/parameters/itemId" 255 | responses: 256 | 200: 257 | $ref: "#/components/responses/TodoItem" 258 | 400: 259 | description: Todo item is invalid 260 | 404: 261 | description: Todo list or item not found 262 | delete: 263 | operationId: DeleteItemById 264 | summary: Deletes a Todo item by unique identifier 265 | tags: 266 | - Items 267 | parameters: 268 | - $ref: "#/components/parameters/listId" 269 | - $ref: "#/components/parameters/itemId" 270 | responses: 271 | 204: 272 | description: Todo item deleted successfully 273 | 404: 274 | description: Todo list or item not found 275 | /lists/{listId}/items/state/{state}: 276 | get: 277 | operationId: GetItemsByListIdAndState 278 | summary: Gets a list of Todo items of a specific state 279 | tags: 280 | - Items 281 | parameters: 282 | - $ref: "#/components/parameters/listId" 283 | - $ref: "#/components/parameters/state" 284 | - $ref: "#/components/parameters/top" 285 | - $ref: "#/components/parameters/skip" 286 | responses: 287 | 200: 288 | $ref: "#/components/responses/TodoItemArray" 289 | 404: 290 | description: Todo list or item not found 291 | put: 292 | operationId: UpdateItemsStateByListId 293 | summary: Changes the state of the specified list items 294 | tags: 295 | - Items 296 | requestBody: 297 | description: unique identifiers of the Todo items to update 298 | content: 299 | application/json: 300 | schema: 301 | type: array 302 | items: 303 | description: The Todo item unique identifier 304 | type: string 305 | parameters: 306 | - $ref: "#/components/parameters/listId" 307 | - $ref: "#/components/parameters/state" 308 | responses: 309 | 204: 310 | description: Todo items updated 311 | 400: 312 | description: Update request is invalid 313 | -------------------------------------------------------------------------------- /src/api/.gitignore: -------------------------------------------------------------------------------- 1 | *env/ 2 | __pycache__ -------------------------------------------------------------------------------- /src/api/.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "Python: FastAPI", 6 | "type": "python", 7 | "request": "launch", 8 | "module": "uvicorn", 9 | "cwd": "${workspaceFolder}/todo", 10 | "args": [ 11 | "todo.app:app", 12 | "--reload" 13 | ], 14 | }, 15 | { 16 | "name": "Python: Pytest", 17 | "type": "python", 18 | "request": "launch", 19 | "module": "pytest", 20 | "cwd": "${workspaceFolder}/todo", 21 | "args": [ 22 | "${workspaceFolder}/tests", 23 | "-vv" 24 | ], 25 | } 26 | ] 27 | } 28 | -------------------------------------------------------------------------------- /src/api/.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "markdownlint.config": { 3 | "MD028": false, 4 | "MD025": { 5 | "front_matter_title": "" 6 | } 7 | } 8 | } -------------------------------------------------------------------------------- /src/api/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.10 2 | WORKDIR /code 3 | EXPOSE 3100 4 | COPY ./requirements.txt /code/requirements.txt 5 | 6 | RUN pip install --no-cache-dir --upgrade -r /code/requirements.txt 7 | 8 | COPY ./todo /code/todo 9 | 10 | CMD ["uvicorn", "todo.app:app", "--host", "0.0.0.0", "--port", "3100", "--proxy-headers"] 11 | -------------------------------------------------------------------------------- /src/api/README.md: -------------------------------------------------------------------------------- 1 | # Python TODO API 2 | 3 | ## Setup 4 | 5 | Requirements: 6 | 7 | - Python (3.8+) 8 | 9 | ```bash 10 | $ pip install -r requirements.txt 11 | ``` 12 | 13 | Or 14 | 15 | ```bash 16 | $ poetry install 17 | ``` 18 | 19 | ## Running 20 | 21 | Before running, set the `AZURE_COSMOS_CONNECTION_STRING` environment variable to the connection-string for mongo/cosmos. 22 | 23 | Run the following common from the root of the api folder to start the app: 24 | 25 | ```bash 26 | $ uvicorn todo.app:app --port 3100 --reload 27 | ``` 28 | 29 | There is also a launch profile in VS Code for debugging. 30 | 31 | ## Running in Docker 32 | 33 | The environment variable AZURE_COSMOS_CONNECTION_STRING must be set and then application runs on TCP 8080: 34 | 35 | ```bash 36 | docker build . -t fastapi-todo 37 | docker run --env-file ./src/.env -p 8080:8080 -t fastapi-todo 38 | ``` 39 | 40 | ## Tests 41 | 42 | The tests can be run from the command line, or the launch profile in VS Code 43 | 44 | ```bash 45 | $ pip install -r requirements-test.txt 46 | $ AZURE_COSMOS_DATABASE_NAME=test_db python -m pytest tests/ 47 | ``` 48 | -------------------------------------------------------------------------------- /src/api/openapi.yaml: -------------------------------------------------------------------------------- 1 | openapi: 3.0.0 2 | info: 3 | description: Simple Todo API 4 | version: 3.0.0 5 | title: Simple Todo API 6 | contact: 7 | email: azdevteam@microsoft.com 8 | 9 | components: 10 | schemas: 11 | TodoItem: 12 | type: object 13 | required: 14 | - listId 15 | - name 16 | - description 17 | description: A task that needs to be completed 18 | properties: 19 | id: 20 | type: string 21 | listId: 22 | type: string 23 | name: 24 | type: string 25 | description: 26 | type: string 27 | state: 28 | $ref: "#/components/schemas/TodoState" 29 | dueDate: 30 | type: string 31 | format: date-time 32 | completedDate: 33 | type: string 34 | format: date-time 35 | TodoList: 36 | type: object 37 | required: 38 | - name 39 | properties: 40 | id: 41 | type: string 42 | name: 43 | type: string 44 | description: 45 | type: string 46 | description: " A list of related Todo items" 47 | TodoState: 48 | type: string 49 | enum: 50 | - todo 51 | - inprogress 52 | - done 53 | parameters: 54 | listId: 55 | in: path 56 | required: true 57 | name: listId 58 | description: The Todo list unique identifier 59 | schema: 60 | type: string 61 | itemId: 62 | in: path 63 | required: true 64 | name: itemId 65 | description: The Todo item unique identifier 66 | schema: 67 | type: string 68 | state: 69 | in: path 70 | required: true 71 | name: state 72 | description: The Todo item state 73 | schema: 74 | $ref: "#/components/schemas/TodoState" 75 | top: 76 | in: query 77 | required: false 78 | name: top 79 | description: The max number of items to returns in a result 80 | schema: 81 | type: number 82 | default: 20 83 | skip: 84 | in: query 85 | required: false 86 | name: skip 87 | description: The number of items to skip within the results 88 | schema: 89 | type: number 90 | default: 0 91 | 92 | requestBodies: 93 | TodoList: 94 | description: The Todo List 95 | content: 96 | application/json: 97 | schema: 98 | $ref: "#/components/schemas/TodoList" 99 | TodoItem: 100 | description: The Todo Item 101 | content: 102 | application/json: 103 | schema: 104 | $ref: "#/components/schemas/TodoItem" 105 | 106 | responses: 107 | TodoList: 108 | description: A Todo list result 109 | content: 110 | application/json: 111 | schema: 112 | $ref: "#/components/schemas/TodoList" 113 | TodoListArray: 114 | description: An array of Todo lists 115 | content: 116 | application/json: 117 | schema: 118 | type: array 119 | items: 120 | $ref: "#/components/schemas/TodoList" 121 | TodoItem: 122 | description: A Todo item result 123 | content: 124 | application/json: 125 | schema: 126 | $ref: "#/components/schemas/TodoItem" 127 | TodoItemArray: 128 | description: An array of Todo items 129 | content: 130 | application/json: 131 | schema: 132 | type: array 133 | items: 134 | $ref: "#/components/schemas/TodoItem" 135 | 136 | paths: 137 | /lists: 138 | get: 139 | operationId: GetLists 140 | summary: Gets an array of Todo lists 141 | tags: 142 | - Lists 143 | parameters: 144 | - $ref: "#/components/parameters/top" 145 | - $ref: "#/components/parameters/skip" 146 | responses: 147 | 200: 148 | $ref: "#/components/responses/TodoListArray" 149 | post: 150 | operationId: CreateList 151 | summary: Creates a new Todo list 152 | tags: 153 | - Lists 154 | requestBody: 155 | $ref: "#/components/requestBodies/TodoList" 156 | responses: 157 | 201: 158 | $ref: "#/components/responses/TodoList" 159 | 400: 160 | description: Invalid request schema 161 | /lists/{listId}: 162 | get: 163 | operationId: GetListById 164 | summary: Gets a Todo list by unique identifier 165 | tags: 166 | - Lists 167 | parameters: 168 | - $ref: "#/components/parameters/listId" 169 | responses: 170 | 200: 171 | $ref: "#/components/responses/TodoList" 172 | 404: 173 | description: Todo list not found 174 | put: 175 | operationId: UpdateListById 176 | summary: Updates a Todo list by unique identifier 177 | tags: 178 | - Lists 179 | requestBody: 180 | $ref: "#/components/requestBodies/TodoList" 181 | parameters: 182 | - $ref: "#/components/parameters/listId" 183 | responses: 184 | 200: 185 | $ref: "#/components/responses/TodoList" 186 | 404: 187 | description: Todo list not found 188 | 400: 189 | description: Todo list is invalid 190 | delete: 191 | operationId: DeleteListById 192 | summary: Deletes a Todo list by unique identifier 193 | tags: 194 | - Lists 195 | parameters: 196 | - $ref: "#/components/parameters/listId" 197 | responses: 198 | 204: 199 | description: Todo list deleted successfully 200 | 404: 201 | description: Todo list not found 202 | /lists/{listId}/items: 203 | post: 204 | operationId: CreateItem 205 | summary: Creates a new Todo item within a list 206 | tags: 207 | - Items 208 | requestBody: 209 | $ref: "#/components/requestBodies/TodoItem" 210 | parameters: 211 | - $ref: "#/components/parameters/listId" 212 | responses: 213 | 201: 214 | $ref: "#/components/responses/TodoItem" 215 | 404: 216 | description: Todo list not found 217 | get: 218 | operationId: GetItemsByListId 219 | summary: Gets Todo items within the specified list 220 | tags: 221 | - Items 222 | parameters: 223 | - $ref: "#/components/parameters/listId" 224 | - $ref: "#/components/parameters/top" 225 | - $ref: "#/components/parameters/skip" 226 | responses: 227 | 200: 228 | $ref: "#/components/responses/TodoItemArray" 229 | 404: 230 | description: Todo list not found 231 | /lists/{listId}/items/{itemId}: 232 | get: 233 | operationId: GetItemById 234 | summary: Gets a Todo item by unique identifier 235 | tags: 236 | - Items 237 | parameters: 238 | - $ref: "#/components/parameters/listId" 239 | - $ref: "#/components/parameters/itemId" 240 | responses: 241 | 200: 242 | $ref: "#/components/responses/TodoItem" 243 | 404: 244 | description: Todo list or item not found 245 | put: 246 | operationId: UpdateItemById 247 | summary: Updates a Todo item by unique identifier 248 | tags: 249 | - Items 250 | requestBody: 251 | $ref: "#/components/requestBodies/TodoItem" 252 | parameters: 253 | - $ref: "#/components/parameters/listId" 254 | - $ref: "#/components/parameters/itemId" 255 | responses: 256 | 200: 257 | $ref: "#/components/responses/TodoItem" 258 | 400: 259 | description: Todo item is invalid 260 | 404: 261 | description: Todo list or item not found 262 | delete: 263 | operationId: DeleteItemById 264 | summary: Deletes a Todo item by unique identifier 265 | tags: 266 | - Items 267 | parameters: 268 | - $ref: "#/components/parameters/listId" 269 | - $ref: "#/components/parameters/itemId" 270 | responses: 271 | 204: 272 | description: Todo item deleted successfully 273 | 404: 274 | description: Todo list or item not found 275 | /lists/{listId}/items/state/{state}: 276 | get: 277 | operationId: GetItemsByListIdAndState 278 | summary: Gets a list of Todo items of a specific state 279 | tags: 280 | - Items 281 | parameters: 282 | - $ref: "#/components/parameters/listId" 283 | - $ref: "#/components/parameters/state" 284 | - $ref: "#/components/parameters/top" 285 | - $ref: "#/components/parameters/skip" 286 | responses: 287 | 200: 288 | $ref: "#/components/responses/TodoItemArray" 289 | 404: 290 | description: Todo list or item not found 291 | put: 292 | operationId: UpdateItemsStateByListId 293 | summary: Changes the state of the specified list items 294 | tags: 295 | - Items 296 | requestBody: 297 | description: unique identifiers of the Todo items to update 298 | content: 299 | application/json: 300 | schema: 301 | type: array 302 | items: 303 | description: The Todo item unique identifier 304 | type: string 305 | parameters: 306 | - $ref: "#/components/parameters/listId" 307 | - $ref: "#/components/parameters/state" 308 | responses: 309 | 204: 310 | description: Todo items updated 311 | 400: 312 | description: Update request is invalid 313 | -------------------------------------------------------------------------------- /src/api/pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "todo" 3 | version = "0.1.0" 4 | description = "Simple Todo API" 5 | license = "MIT" 6 | packages = [ 7 | { include = "todo" }, 8 | ] 9 | 10 | [tool.poetry.dependencies] 11 | python = "^3.10" 12 | fastapi = "*" 13 | uvicorn = "*" 14 | beanie = "*" 15 | python-dotenv = "*" 16 | 17 | [tool.poetry.dev-dependencies] 18 | pytest = "*" 19 | pytest-asyncio = "*" 20 | 21 | [build-system] 22 | requires = ["poetry-core>=1.0.0"] 23 | build-backend = "poetry.core.masonry.api" 24 | -------------------------------------------------------------------------------- /src/api/requirements-test.txt: -------------------------------------------------------------------------------- 1 | pytest>5 2 | pytest-asyncio -------------------------------------------------------------------------------- /src/api/requirements.txt: -------------------------------------------------------------------------------- 1 | fastapi == 0.95.* 2 | uvicorn == 0.19.* 3 | beanie == 1.11.* 4 | python-dotenv == 0.20.* 5 | # 1.13.0b4 has a update supporting configurable timeouts on AzureDeveloperCredential 6 | # https://github.com/Azure/azure-sdk-for-python/blob/main/sdk/identity/azure-identity/CHANGELOG.md#1130b4-2023-04-11 7 | azure-identity == 1.21.0 8 | azure-keyvault-secrets == 4.4.* 9 | opentelemetry-instrumentation-fastapi == 0.42b0 10 | azure-monitor-opentelemetry-exporter == 1.0.0b19 11 | -------------------------------------------------------------------------------- /src/api/tests/conftest.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | import motor 4 | import pytest 5 | from fastapi.testclient import TestClient 6 | from todo.app import app, settings 7 | 8 | TEST_DB_NAME = "test_db" 9 | 10 | 11 | @pytest.fixture(scope="session") 12 | def event_loop(): 13 | """ 14 | Redefine the event_loop fixture to be session scoped. 15 | Requirement of pytest-asyncio if there are async fixtures 16 | with non-function scope. 17 | """ 18 | try: 19 | return asyncio.get_running_loop() 20 | except RuntimeError: 21 | return asyncio.new_event_loop() 22 | 23 | 24 | @pytest.fixture() 25 | def app_client(): 26 | with TestClient(app) as client: 27 | yield client 28 | 29 | 30 | @pytest.fixture(scope="session", autouse=True) 31 | async def initialize_database(): 32 | settings.AZURE_COSMOS_DATABASE_NAME = TEST_DB_NAME 33 | mongo_client = motor.motor_asyncio.AsyncIOMotorClient( 34 | settings.AZURE_COSMOS_CONNECTION_STRING 35 | ) 36 | await mongo_client.drop_database(TEST_DB_NAME) 37 | yield 38 | await mongo_client.drop_database(TEST_DB_NAME) 39 | -------------------------------------------------------------------------------- /src/api/tests/test_main.py: -------------------------------------------------------------------------------- 1 | def test_list(app_client): 2 | # Create 2 test locations 3 | first = app_client.post( 4 | "/lists", 5 | json={ 6 | "name": "Test List 1", 7 | "description": "My first test list", 8 | }, 9 | ) 10 | assert first.status_code == 201 11 | assert first.headers["Location"].startswith("http://testserver/lists/") 12 | assert ( 13 | app_client.post( 14 | "/lists", 15 | json={ 16 | "name": "Test List 2", 17 | "description": "My second test list", 18 | }, 19 | ).status_code 20 | == 201 21 | ) 22 | 23 | # Get all lists 24 | response = app_client.get("/lists") 25 | assert response.status_code == 200 26 | result = response.json() 27 | assert len(result) == 2 28 | assert result[0]["name"] == "Test List 1" 29 | assert result[1]["name"] == "Test List 2" 30 | assert result[0]["createdDate"] is not None 31 | assert result[1]["createdDate"] is not None 32 | 33 | # Test those lists at the ID URL 34 | assert result[0]["id"] is not None 35 | test_list_id = result[0]["id"] 36 | test_list_id2 = result[1]["id"] 37 | 38 | response = app_client.get("/lists/{0}".format(test_list_id)) 39 | assert response.status_code == 200 40 | result = response.json() 41 | assert result["name"] == "Test List 1" 42 | assert result["description"] == "My first test list" 43 | 44 | # Test a list with a bad ID 45 | response = app_client.get("/lists/{0}".format("61958439e0dbd854f5ab9000")) 46 | assert response.status_code == 404 47 | 48 | # Update a list 49 | response = app_client.put( 50 | "/lists/{0}".format(test_list_id), 51 | json={ 52 | "name": "Test List 1 Updated", 53 | }, 54 | ) 55 | assert response.status_code == 200 56 | result = response.json() 57 | assert result["name"] == "Test List 1 Updated" 58 | assert result["updatedDate"] is not None 59 | 60 | # Delete a list 61 | response = app_client.delete("/lists/{0}".format(test_list_id2)) 62 | assert response.status_code == 204 63 | response = app_client.get("/lists/{0}".format(test_list_id2)) 64 | assert response.status_code == 404 65 | 66 | # Create a list item 67 | response = app_client.post( 68 | "/lists/{0}/items".format(test_list_id), 69 | json={ 70 | "name": "Test Item 1", 71 | "description": "My first test item", 72 | }, 73 | ) 74 | assert response.status_code == 201 75 | assert response.headers["Location"].startswith("http://testserver/lists/") 76 | assert "items/" in response.headers["Location"] 77 | 78 | 79 | # Get all list items 80 | response = app_client.get("/lists/{0}/items".format(test_list_id)) 81 | assert response.status_code == 200 82 | result = response.json() 83 | assert len(result) == 1 84 | assert result[0]["name"] == "Test Item 1" 85 | assert result[0]["description"] == "My first test item" 86 | assert result[0]["createdDate"] is not None 87 | test_item_id = result[0]["id"] 88 | 89 | # Update list item 90 | response = app_client.put( 91 | "/lists/{0}/items/{1}".format(test_list_id, test_item_id), 92 | json={ 93 | "name": "Test Item 1 Updated", 94 | "description": "My first test item", 95 | "state": "done", 96 | }, 97 | ) 98 | assert response.status_code == 200, response.text 99 | 100 | # Get list item by id 101 | response = app_client.get("/lists/{0}/items/{1}".format(test_list_id, test_item_id)) 102 | assert response.status_code == 200 103 | result = response.json() 104 | assert result["name"] == "Test Item 1 Updated" 105 | assert result["state"] == "done" 106 | assert result["updatedDate"] is not None 107 | 108 | # Get list items by state 109 | response = app_client.get("/lists/{0}/items/state/done".format(test_list_id)) 110 | assert response.status_code == 200 111 | result = response.json() 112 | assert len(result) == 1 113 | assert result[0]["name"] == "Test Item 1 Updated" 114 | assert result[0]["state"] == "done" 115 | 116 | # Delete list item 117 | response = app_client.delete( 118 | "/lists/{0}/items/{1}".format(test_list_id, test_item_id) 119 | ) 120 | assert response.status_code == 204 121 | -------------------------------------------------------------------------------- /src/api/todo/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Azure-Samples/todo-python-mongo/58f5d327e15c8748c8a6f3172066dfa70e1efc37/src/api/todo/__init__.py -------------------------------------------------------------------------------- /src/api/todo/app.py: -------------------------------------------------------------------------------- 1 | import motor 2 | from azure.monitor.opentelemetry.exporter import AzureMonitorTraceExporter 3 | from beanie import init_beanie 4 | from fastapi import FastAPI 5 | from fastapi.middleware.cors import CORSMiddleware 6 | from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor 7 | from opentelemetry.sdk.resources import SERVICE_NAME, Resource 8 | from opentelemetry.sdk.trace import TracerProvider 9 | from opentelemetry.sdk.trace.export import BatchSpanProcessor 10 | import os 11 | from pathlib import Path 12 | 13 | # Use API_ALLOW_ORIGINS env var with comma separated urls like 14 | # `http://localhost:300, http://otherurl:100` 15 | # Requests coming to the api server from other urls will be rejected as per 16 | # CORS. 17 | allowOrigins = os.environ.get('API_ALLOW_ORIGINS') 18 | 19 | # Use API_ENVIRONMENT to change webConfiguration based on this value. 20 | # For example, setting API_ENVIRONMENT=develop disables CORS checking, 21 | # allowing all origins. 22 | environment = os.environ.get('API_ENVIRONMENT') 23 | 24 | def originList(): 25 | if environment is not None and environment == "develop": 26 | print("Allowing requests from any origins. API_ENVIRONMENT=", environment) 27 | return ["*"] 28 | 29 | origins = [ 30 | "https://portal.azure.com", 31 | "https://ms.portal.azure.com", 32 | ] 33 | 34 | if allowOrigins is not None: 35 | for origin in allowOrigins.split(","): 36 | print("Allowing requests from", origin, ". To change or disable, go to ", Path(__file__)) 37 | origins.append(origin) 38 | 39 | return origins 40 | 41 | from .models import Settings, __beanie_models__ 42 | 43 | settings = Settings() 44 | app = FastAPI( 45 | description="Simple Todo API", 46 | version="2.0.0", 47 | title="Simple Todo API", 48 | docs_url="/", 49 | ) 50 | app.add_middleware( 51 | CORSMiddleware, 52 | allow_origins=originList(), 53 | allow_credentials=True, 54 | allow_methods=["*"], 55 | allow_headers=["*"], 56 | ) 57 | 58 | if settings.APPLICATIONINSIGHTS_CONNECTION_STRING: 59 | exporter = AzureMonitorTraceExporter.from_connection_string( 60 | settings.APPLICATIONINSIGHTS_CONNECTION_STRING 61 | ) 62 | tracerProvider = TracerProvider( 63 | resource=Resource({SERVICE_NAME: settings.APPLICATIONINSIGHTS_ROLENAME}) 64 | ) 65 | tracerProvider.add_span_processor(BatchSpanProcessor(exporter)) 66 | 67 | FastAPIInstrumentor.instrument_app(app, tracer_provider=tracerProvider) 68 | 69 | 70 | from . import routes # NOQA 71 | 72 | @app.on_event("startup") 73 | async def startup_event(): 74 | client = motor.motor_asyncio.AsyncIOMotorClient( 75 | settings.AZURE_COSMOS_CONNECTION_STRING 76 | ) 77 | await init_beanie( 78 | database=client[settings.AZURE_COSMOS_DATABASE_NAME], 79 | document_models=__beanie_models__, 80 | ) 81 | -------------------------------------------------------------------------------- /src/api/todo/models.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from enum import Enum 3 | from typing import Optional 4 | 5 | from azure.identity import DefaultAzureCredential 6 | from azure.keyvault.secrets import SecretClient 7 | from beanie import Document, PydanticObjectId 8 | from pydantic import BaseModel, BaseSettings 9 | 10 | def keyvault_name_as_attr(name: str) -> str: 11 | return name.replace("-", "_").upper() 12 | 13 | 14 | class Settings(BaseSettings): 15 | def __init__(self, *args, **kwargs): 16 | super().__init__(*args, **kwargs) 17 | 18 | # Load secrets from keyvault 19 | if self.AZURE_KEY_VAULT_ENDPOINT: 20 | credential = DefaultAzureCredential() 21 | keyvault_client = SecretClient(self.AZURE_KEY_VAULT_ENDPOINT, credential) 22 | for secret in keyvault_client.list_properties_of_secrets(): 23 | setattr( 24 | self, 25 | keyvault_name_as_attr(secret.name), 26 | keyvault_client.get_secret(secret.name).value, 27 | ) 28 | 29 | AZURE_COSMOS_CONNECTION_STRING: str = "" 30 | AZURE_COSMOS_DATABASE_NAME: str = "Todo" 31 | AZURE_KEY_VAULT_ENDPOINT: Optional[str] = None 32 | APPLICATIONINSIGHTS_CONNECTION_STRING: Optional[str] = None 33 | APPLICATIONINSIGHTS_ROLENAME: Optional[str] = "API" 34 | 35 | class Config: 36 | env_file = ".env" 37 | env_file_encoding = "utf-8" 38 | 39 | 40 | class TodoList(Document): 41 | name: str 42 | description: Optional[str] = None 43 | createdDate: Optional[datetime] = None 44 | updatedDate: Optional[datetime] = None 45 | 46 | 47 | class CreateUpdateTodoList(BaseModel): 48 | name: str 49 | description: Optional[str] = None 50 | 51 | 52 | class TodoState(Enum): 53 | TODO = "todo" 54 | INPROGRESS = "inprogress" 55 | DONE = "done" 56 | 57 | 58 | class TodoItem(Document): 59 | listId: PydanticObjectId 60 | name: str 61 | description: Optional[str] = None 62 | state: Optional[TodoState] = None 63 | dueDate: Optional[datetime] = None 64 | completedDate: Optional[datetime] = None 65 | createdDate: Optional[datetime] = None 66 | updatedDate: Optional[datetime] = None 67 | 68 | 69 | class CreateUpdateTodoItem(BaseModel): 70 | name: str 71 | description: Optional[str] = None 72 | state: Optional[TodoState] = None 73 | dueDate: Optional[datetime] = None 74 | completedDate: Optional[datetime] = None 75 | 76 | 77 | __beanie_models__ = [TodoList, TodoItem] 78 | -------------------------------------------------------------------------------- /src/api/todo/routes.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from http import HTTPStatus 3 | from typing import List, Optional 4 | from urllib.parse import urljoin 5 | 6 | from beanie import PydanticObjectId 7 | from fastapi import HTTPException, Response 8 | from starlette.requests import Request 9 | 10 | from .app import app 11 | from .models import (CreateUpdateTodoItem, CreateUpdateTodoList, TodoItem, 12 | TodoList, TodoState) 13 | 14 | 15 | @app.get("/lists", response_model=List[TodoList], response_model_by_alias=False) 16 | async def get_lists( 17 | top: Optional[int] = None, skip: Optional[int] = None 18 | ) -> List[TodoList]: 19 | """ 20 | Get all Todo lists 21 | 22 | Optional arguments: 23 | 24 | - **top**: Number of lists to return 25 | - **skip**: Number of lists to skip 26 | """ 27 | query = TodoList.all().skip(skip).limit(top) 28 | return await query.to_list() 29 | 30 | 31 | @app.post("/lists", response_model=TodoList, response_model_by_alias=False, status_code=201) 32 | async def create_list(body: CreateUpdateTodoList, request: Request, response: Response) -> TodoList: 33 | """ 34 | Create a new Todo list 35 | """ 36 | todo_list = await TodoList(**body.dict(), createdDate=datetime.utcnow()).save() 37 | response.headers["Location"] = urljoin(str(request.base_url), "lists/{0}".format(str(todo_list.id))) 38 | return todo_list 39 | 40 | 41 | @app.get("/lists/{list_id}", response_model=TodoList, response_model_by_alias=False) 42 | async def get_list(list_id: PydanticObjectId) -> TodoList: 43 | """ 44 | Get Todo list by ID 45 | """ 46 | todo_list = await TodoList.get(document_id=list_id) 47 | if not todo_list: 48 | raise HTTPException(status_code=404, detail="Todo list not found") 49 | return todo_list 50 | 51 | 52 | @app.put("/lists/{list_id}", response_model=TodoList, response_model_by_alias=False) 53 | async def update_list( 54 | list_id: PydanticObjectId, body: CreateUpdateTodoList 55 | ) -> TodoList: 56 | """ 57 | Updates a Todo list by unique identifier 58 | """ 59 | todo_list = await TodoList.get(document_id=list_id) 60 | if not todo_list: 61 | raise HTTPException(status_code=404, detail="Todo list not found") 62 | await todo_list.update({"$set": body.dict(exclude_unset=True)}) 63 | todo_list.updatedDate = datetime.utcnow() 64 | return await todo_list.save() 65 | 66 | 67 | @app.delete("/lists/{list_id}", response_class=Response, status_code=204) 68 | async def delete_list(list_id: PydanticObjectId) -> None: 69 | """ 70 | Deletes a Todo list by unique identifier 71 | """ 72 | todo_list = await TodoList.get(document_id=list_id) 73 | if not todo_list: 74 | raise HTTPException(status_code=404, detail="Todo list not found") 75 | await todo_list.delete() 76 | 77 | 78 | @app.post("/lists/{list_id}/items", response_model=TodoItem, response_model_by_alias=False, status_code=201) 79 | async def create_list_item( 80 | list_id: PydanticObjectId, body: CreateUpdateTodoItem, request: Request, response: Response 81 | ) -> TodoItem: 82 | """ 83 | Creates a new Todo item within a list 84 | """ 85 | item = TodoItem(listId=list_id, **body.dict(), createdDate=datetime.utcnow()) 86 | response.headers["Location"] = urljoin(str(request.base_url), "lists/{0}/items/{1}".format(str(list_id), str(item.id))) 87 | return await item.save() 88 | 89 | 90 | @app.get("/lists/{list_id}/items", response_model=List[TodoItem], response_model_by_alias=False) 91 | async def get_list_items( 92 | list_id: PydanticObjectId, 93 | top: Optional[int] = None, 94 | skip: Optional[int] = None, 95 | ) -> List[TodoItem]: 96 | """ 97 | Gets Todo items within the specified list 98 | 99 | Optional arguments: 100 | 101 | - **top**: Number of lists to return 102 | - **skip**: Number of lists to skip 103 | """ 104 | query = TodoItem.find(TodoItem.listId == list_id).skip(skip).limit(top) 105 | return await query.to_list() 106 | 107 | 108 | @app.get("/lists/{list_id}/items/state/{state}", response_model=List[TodoItem], response_model_by_alias=False) 109 | async def get_list_items_by_state( 110 | list_id: PydanticObjectId, 111 | state: TodoState = ..., 112 | top: Optional[int] = None, 113 | skip: Optional[int] = None, 114 | ) -> List[TodoItem]: 115 | """ 116 | Gets a list of Todo items of a specific state 117 | 118 | Optional arguments: 119 | 120 | - **top**: Number of lists to return 121 | - **skip**: Number of lists to skip 122 | """ 123 | query = ( 124 | TodoItem.find(TodoItem.listId == list_id, TodoItem.state == state) 125 | .skip(skip) 126 | .limit(top) 127 | ) 128 | return await query.to_list() 129 | 130 | 131 | @app.put("/lists/{list_id}/items/state/{state}", response_model=List[TodoItem], response_model_by_alias=False) 132 | async def update_list_items_state( 133 | list_id: PydanticObjectId, 134 | state: TodoState = ..., 135 | body: List[str] = None, 136 | ) -> List[TodoItem]: 137 | """ 138 | Changes the state of the specified list items 139 | """ 140 | if not body: 141 | raise HTTPException(status_code=400, detail="No items specified") 142 | results = [] 143 | for id_ in body: 144 | item = await TodoItem.get(document_id=id_) 145 | if not item: 146 | raise HTTPException(status_code=404, detail="Todo item not found") 147 | item.state = state 148 | item.updatedDate = datetime.utcnow() 149 | results.append(await item.save()) 150 | return results 151 | 152 | 153 | @app.get("/lists/{list_id}/items/{item_id}", response_model=TodoItem, response_model_by_alias=False) 154 | async def get_list_item( 155 | list_id: PydanticObjectId, item_id: PydanticObjectId 156 | ) -> TodoItem: 157 | """ 158 | Gets a Todo item by unique identifier 159 | """ 160 | item = await TodoItem.find_one(TodoItem.listId == list_id, TodoItem.id == item_id) 161 | if not item: 162 | raise HTTPException(status_code=404, detail="Todo item not found") 163 | return item 164 | 165 | 166 | @app.put("/lists/{list_id}/items/{item_id}", response_model=TodoItem, response_model_by_alias=False) 167 | async def update_list_item( 168 | list_id: PydanticObjectId, 169 | item_id: PydanticObjectId, 170 | body: CreateUpdateTodoItem, 171 | ) -> TodoItem: 172 | """ 173 | Updates a Todo item by unique identifier 174 | """ 175 | item = await TodoItem.find_one(TodoItem.listId == list_id, TodoItem.id == item_id) 176 | if not item: 177 | raise HTTPException(status_code=404, detail="Todo item not found") 178 | await item.update({"$set": body.dict(exclude_unset=True)}) 179 | item.updatedDate = datetime.utcnow() 180 | return await item.save() 181 | 182 | 183 | @app.delete("/lists/{list_id}/items/{item_id}", response_class=Response, status_code=204) 184 | async def delete_list_item( 185 | list_id: PydanticObjectId, item_id: PydanticObjectId 186 | ) -> None: 187 | """ 188 | Deletes a Todo item by unique identifier 189 | """ 190 | todo_item = await TodoItem.find_one(TodoItem.id == item_id) 191 | if not todo_item: 192 | raise HTTPException(status_code=404, detail="Todo item not found") 193 | await todo_item.delete() 194 | -------------------------------------------------------------------------------- /src/web/.dockerignore: -------------------------------------------------------------------------------- 1 | **/.devcontainer 2 | **/build 3 | **/node_modules 4 | README.md 5 | -------------------------------------------------------------------------------- /src/web/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { browser: true, es2020: true }, 4 | extends: [ 5 | 'eslint:recommended', 6 | 'plugin:@typescript-eslint/recommended', 7 | 'plugin:react-hooks/recommended', 8 | ], 9 | ignorePatterns: ['dist', '.eslintrc.cjs'], 10 | parser: '@typescript-eslint/parser', 11 | plugins: ['react-refresh'], 12 | rules: { 13 | 'react-refresh/only-export-components': [ 14 | 'warn', 15 | { allowConstantExport: true }, 16 | ], 17 | }, 18 | } 19 | -------------------------------------------------------------------------------- /src/web/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /dist 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | .eslintcache 25 | 26 | env-config.js -------------------------------------------------------------------------------- /src/web/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:20-alpine AS build 2 | 3 | # make the 'app' folder the current working directory 4 | WORKDIR /app 5 | 6 | COPY . . 7 | 8 | # install project dependencies 9 | RUN npm ci 10 | RUN npm run build 11 | 12 | FROM nginx:alpine 13 | 14 | WORKDIR /usr/share/nginx/html 15 | COPY --from=build /app/dist . 16 | COPY --from=build /app/nginx/nginx.conf /etc/nginx/conf.d/default.conf 17 | 18 | EXPOSE 80 19 | 20 | CMD ["/bin/sh", "-c", "nginx -g \"daemon off;\""] 21 | -------------------------------------------------------------------------------- /src/web/README.md: -------------------------------------------------------------------------------- 1 | # Getting Started with Create React App and Fluent UI 2 | 3 | This is a [Create React App](https://github.com/facebook/create-react-app) based repo that comes with Fluent UI pre-installed! 4 | 5 | ## Setup 6 | 7 | Create a `.env` file within the base of the `reactd-fluent` folder with the following configuration: 8 | 9 | - `VITE_API_BASE_URL` - Base URL for all api requests, (ex: `http://localhost:3100`) 10 | 11 | > Note: The URL must include the schema, either `http://` or `https://`. 12 | 13 | - `VITE_APPLICATIONINSIGHTS_CONNECTION_STRING` - Azure Application Insights connection string 14 | 15 | ## Available Scripts 16 | 17 | In the project directory, you can run: 18 | 19 | ### `npm ci` 20 | 21 | Installs local pre-requisites. 22 | 23 | ### `npm start` 24 | 25 | Runs the app in the development mode. 26 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser. 27 | 28 | The page will reload if you make edits. 29 | You will also see any lint errors in the console. 30 | 31 | ### `npm test` 32 | 33 | Launches the test runner in the interactive watch mode. 34 | See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information. 35 | 36 | ### `npm run build` 37 | 38 | Builds the app for production to the `build` folder. 39 | It correctly bundles React in production mode and optimizes the build for the best performance. 40 | 41 | The build is minified and the filenames include the hashes. 42 | Your app is ready to be deployed! 43 | 44 | See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information. 45 | 46 | ### `npm run eject` 47 | 48 | **Note: this is a one-way operation. Once you `eject`, you can’t go back!** 49 | 50 | If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project. 51 | 52 | Instead, it will copy all the configuration files and the transitive dependencies (Webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own. 53 | 54 | You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it. 55 | 56 | ## Learn More 57 | 58 | You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started). 59 | 60 | To learn React, check out the [React documentation](https://reactjs.org/). 61 | 62 | ## Contributing 63 | 64 | This project welcomes contributions and suggestions. Most contributions require you to agree to a 65 | Contributor License Agreement (CLA) declaring that you have the right to, and actually do, grant us 66 | the rights to use your contribution. For details, visit https://cla.microsoft.com. 67 | 68 | When you submit a pull request, a CLA-bot will automatically determine whether you need to provide 69 | a CLA and decorate the PR appropriately (e.g., label, comment). Simply follow the instructions 70 | provided by the bot. You will only need to do this once across all repos using our CLA. 71 | 72 | This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). 73 | For more information see the [Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) or 74 | contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with any additional questions or comments. 75 | -------------------------------------------------------------------------------- /src/web/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 13 | 14 | 23 | 24 | AzDev Todo 25 | 26 | 27 | 28 |
29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /src/web/nginx/nginx.conf: -------------------------------------------------------------------------------- 1 | server { 2 | listen 80; 3 | location / { 4 | root /usr/share/nginx/html; 5 | index index.html index.htm; 6 | try_files $uri $uri/ /index.html =404; 7 | } 8 | } -------------------------------------------------------------------------------- /src/web/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "todo-vite", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "tsc && vite build", 9 | "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", 10 | "preview": "vite preview" 11 | }, 12 | "dependencies": { 13 | "@babel/plugin-proposal-private-property-in-object": "^7.21.11", 14 | "@fluentui/react": "^8.73.0", 15 | "@microsoft/applicationinsights-react-js": "^17.0.3", 16 | "@microsoft/applicationinsights-web": "^3.0.7", 17 | "axios": "^1.8.2", 18 | "history": "^5.3.0", 19 | "dotenv": "^16.3.1", 20 | "react": "^18.2.0", 21 | "react-dom": "^18.2.0", 22 | "react-router-dom": "^7.5.2", 23 | "web-vitals": "^3.5.1" 24 | }, 25 | "devDependencies": { 26 | "@types/react": "^18.2.43", 27 | "@types/react-dom": "^18.2.17", 28 | "@typescript-eslint/eslint-plugin": "^6.14.0", 29 | "@typescript-eslint/parser": "^6.14.0", 30 | "@vitejs/plugin-react-swc": "^3.8.0", 31 | "eslint": "^8.55.0", 32 | "eslint-plugin-react-hooks": "^4.6.0", 33 | "eslint-plugin-react-refresh": "^0.4.5", 34 | "typescript": "^5.2.2", 35 | "vite": "^6.3.4" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/web/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Azure-Samples/todo-python-mongo/58f5d327e15c8748c8a6f3172066dfa70e1efc37/src/web/public/favicon.ico -------------------------------------------------------------------------------- /src/web/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | } 10 | ], 11 | "start_url": ".", 12 | "display": "standalone", 13 | "theme_color": "#0392ff", 14 | "background_color": "#fcfcfc" 15 | } 16 | -------------------------------------------------------------------------------- /src/web/src/@types/window.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | interface ImportMetaEnv { 4 | readonly VITE_API_BASE_URL: string; 5 | readonly VITE_APPLICATIONINSIGHTS_CONNECTION_STRING: string; 6 | } 7 | 8 | interface ImportMeta { 9 | readonly env: ImportMetaEnv 10 | } 11 | -------------------------------------------------------------------------------- /src/web/src/App.css: -------------------------------------------------------------------------------- 1 | .App { 2 | height: 100vh; 3 | } 4 | 5 | .App-logo { 6 | height: 40vmin; 7 | pointer-events: none; 8 | } 9 | 10 | @media (prefers-reduced-motion: no-preference) { 11 | .App-logo { 12 | animation: App-logo-spin infinite 20s linear; 13 | } 14 | } 15 | 16 | .App-header { 17 | background-color: #282c34; 18 | min-height: 100vh; 19 | display: flex; 20 | flex-direction: column; 21 | align-items: center; 22 | justify-content: center; 23 | font-size: calc(10px + 2vmin); 24 | color: white; 25 | } 26 | 27 | .App-link { 28 | color: #61dafb; 29 | } 30 | 31 | @keyframes App-logo-spin { 32 | from { 33 | transform: rotate(0deg); 34 | } 35 | to { 36 | transform: rotate(360deg); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/web/src/App.tsx: -------------------------------------------------------------------------------- 1 | import { useReducer, FC } from 'react'; 2 | import { BrowserRouter } from 'react-router-dom'; 3 | import Layout from './layout/layout'; 4 | import './App.css'; 5 | import { DarkTheme } from './ux/theme'; 6 | import { AppContext, ApplicationState, getDefaultState } from './models/applicationState'; 7 | import appReducer from './reducers'; 8 | import { TodoContext } from './components/todoContext'; 9 | import { initializeIcons } from '@fluentui/react/lib/Icons'; 10 | import { ThemeProvider } from '@fluentui/react'; 11 | import Telemetry from './components/telemetry'; 12 | 13 | initializeIcons(undefined, { disableWarnings: true }); 14 | 15 | const App: FC = () => { 16 | const defaultState: ApplicationState = getDefaultState(); 17 | const [applicationState, dispatch] = useReducer(appReducer, defaultState); 18 | const initialContext: AppContext = { state: applicationState, dispatch: dispatch } 19 | 20 | return ( 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | ); 31 | }; 32 | 33 | export default App; 34 | -------------------------------------------------------------------------------- /src/web/src/actions/actionCreators.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | import { Dispatch } from "react"; 3 | 4 | export interface Action { 5 | type: T 6 | } 7 | 8 | export interface AnyAction extends Action { 9 | [extraProps: string]: any 10 | } 11 | 12 | export interface ActionCreator { 13 | (...args: P): A 14 | } 15 | 16 | export interface ActionCreatorsMapObject { 17 | [key: string]: ActionCreator 18 | } 19 | 20 | export type ActionMethod = (dispatch: Dispatch) => Promise; 21 | 22 | export interface PayloadAction extends Action { 23 | payload: TPayload; 24 | } 25 | 26 | export function createAction>(type: TAction["type"]): () => Action { 27 | return () => ({ 28 | type, 29 | }); 30 | } 31 | 32 | export function createPayloadAction>(type: TAction["type"]): (payload: TAction["payload"]) => PayloadAction { 33 | return (payload: TAction["payload"]) => ({ 34 | type, 35 | payload, 36 | }); 37 | } 38 | 39 | export type BoundActionMethod = (...args: A[]) => Promise; 40 | export type BoundActionsMapObject = { [key: string]: BoundActionMethod } 41 | 42 | function bindActionCreator(actionCreator: ActionCreator, dispatch: Dispatch): BoundActionMethod { 43 | return async function (this: any, ...args: any[]) { 44 | const actionMethod = actionCreator.apply(this, args) as any as ActionMethod; 45 | return await actionMethod(dispatch); 46 | } 47 | } 48 | 49 | export function bindActionCreators( 50 | actionCreators: ActionCreator | ActionCreatorsMapObject, 51 | dispatch: Dispatch 52 | ): BoundActionsMapObject | BoundActionMethod { 53 | if (typeof actionCreators === 'function') { 54 | return bindActionCreator(actionCreators, dispatch) 55 | } 56 | 57 | if (typeof actionCreators !== 'object' || actionCreators === null) { 58 | throw new Error('bindActionCreators expected an object or a function, did you write "import ActionCreators from" instead of "import * as ActionCreators from"?') 59 | } 60 | 61 | const boundActionCreators: ActionCreatorsMapObject = {} 62 | for (const key in actionCreators) { 63 | const actionCreator = actionCreators[key] 64 | if (typeof actionCreator === 'function') { 65 | boundActionCreators[key] = bindActionCreator(actionCreator, dispatch) 66 | } 67 | } 68 | return boundActionCreators 69 | } -------------------------------------------------------------------------------- /src/web/src/actions/common.ts: -------------------------------------------------------------------------------- 1 | import * as itemActions from './itemActions'; 2 | import * as listActions from './listActions'; 3 | 4 | export enum ActionTypes { 5 | LOAD_TODO_LISTS = "LOAD_TODO_LISTS", 6 | LOAD_TODO_LIST = "LOAD_TODO_LIST", 7 | SELECT_TODO_LIST = "SELECT_TODO_LIST", 8 | SAVE_TODO_LIST = "SAVE_TODO_LIST", 9 | DELETE_TODO_LIST = "DELETE_TODO_LIST", 10 | LOAD_TODO_ITEMS = "LOAD_TODO_ITEMS", 11 | LOAD_TODO_ITEM = "LOAD_TODO_ITEM", 12 | SELECT_TODO_ITEM = "SELECT_TODO_ITEM", 13 | SAVE_TODO_ITEM = "SAVE_TODO_ITEM", 14 | DELETE_TODO_ITEM = "DELETE_TODO_ITEM" 15 | } 16 | 17 | export type TodoActions = 18 | itemActions.ListItemsAction | 19 | itemActions.SelectItemAction | 20 | itemActions.LoadItemAction | 21 | itemActions.SaveItemAction | 22 | itemActions.DeleteItemAction | 23 | listActions.ListListsAction | 24 | listActions.SelectListAction | 25 | listActions.LoadListAction | 26 | listActions.SaveListAction | 27 | listActions.DeleteListAction; -------------------------------------------------------------------------------- /src/web/src/actions/itemActions.ts: -------------------------------------------------------------------------------- 1 | import { Dispatch } from "react"; 2 | import { TodoItem } from "../models"; 3 | import { ItemService } from "../services/itemService"; 4 | import { ActionTypes } from "./common"; 5 | import config from "../config" 6 | import { ActionMethod, createPayloadAction, PayloadAction } from "./actionCreators"; 7 | 8 | export interface QueryOptions { 9 | [key: string]: RegExp | boolean 10 | } 11 | 12 | export interface ItemActions { 13 | list(listId: string, options?: QueryOptions): Promise 14 | select(item?: TodoItem): Promise 15 | load(listId: string, id: string): Promise 16 | save(listId: string, Item: TodoItem): Promise 17 | remove(listId: string, Item: TodoItem): Promise 18 | } 19 | 20 | export const list = (listId: string, options?: QueryOptions): ActionMethod => async (dispatch: Dispatch) => { 21 | const itemService = new ItemService(config.api.baseUrl, `/lists/${listId}/items`); 22 | const items = await itemService.getList(options); 23 | 24 | dispatch(listItemsAction(items)); 25 | 26 | return items; 27 | } 28 | 29 | export const select = (item?: TodoItem): ActionMethod => async (dispatch: Dispatch) => { 30 | dispatch(selectItemAction(item)); 31 | 32 | return Promise.resolve(item); 33 | } 34 | 35 | export const load = (listId: string, id: string): ActionMethod => async (dispatch: Dispatch) => { 36 | const itemService = new ItemService(config.api.baseUrl, `/lists/${listId}/items`); 37 | const item = await itemService.get(id); 38 | 39 | dispatch(loadItemAction(item)); 40 | 41 | return item; 42 | } 43 | 44 | export const save = (listId: string, item: TodoItem): ActionMethod => async (dispatch: Dispatch) => { 45 | const itemService = new ItemService(config.api.baseUrl, `/lists/${listId}/items`); 46 | const newItem = await itemService.save(item); 47 | 48 | dispatch(saveItemAction(newItem)); 49 | 50 | return newItem; 51 | } 52 | 53 | export const remove = (listId: string, item: TodoItem): ActionMethod => async (dispatch: Dispatch) => { 54 | const itemService = new ItemService(config.api.baseUrl, `/lists/${listId}/items`); 55 | if (item.id) { 56 | await itemService.delete(item.id); 57 | dispatch(deleteItemAction(item.id)); 58 | } 59 | } 60 | 61 | export interface ListItemsAction extends PayloadAction { 62 | type: ActionTypes.LOAD_TODO_ITEMS 63 | } 64 | 65 | export interface SelectItemAction extends PayloadAction { 66 | type: ActionTypes.SELECT_TODO_ITEM 67 | } 68 | 69 | export interface LoadItemAction extends PayloadAction { 70 | type: ActionTypes.LOAD_TODO_ITEM 71 | } 72 | 73 | export interface SaveItemAction extends PayloadAction { 74 | type: ActionTypes.SAVE_TODO_ITEM 75 | } 76 | 77 | export interface DeleteItemAction extends PayloadAction { 78 | type: ActionTypes.DELETE_TODO_ITEM 79 | } 80 | 81 | const listItemsAction = createPayloadAction(ActionTypes.LOAD_TODO_ITEMS); 82 | const selectItemAction = createPayloadAction(ActionTypes.SELECT_TODO_ITEM); 83 | const loadItemAction = createPayloadAction(ActionTypes.LOAD_TODO_ITEM); 84 | const saveItemAction = createPayloadAction(ActionTypes.SAVE_TODO_ITEM); 85 | const deleteItemAction = createPayloadAction(ActionTypes.DELETE_TODO_ITEM); 86 | -------------------------------------------------------------------------------- /src/web/src/actions/listActions.ts: -------------------------------------------------------------------------------- 1 | import { Dispatch } from "react"; 2 | import { TodoList } from "../models"; 3 | import { ListService } from "../services/listService"; 4 | import { ActionTypes } from "./common"; 5 | import config from "../config" 6 | import { trackEvent } from "../services/telemetryService"; 7 | import { ActionMethod, createPayloadAction, PayloadAction } from "./actionCreators"; 8 | import { QueryOptions } from "./itemActions"; 9 | 10 | const listService = new ListService(config.api.baseUrl, '/lists'); 11 | 12 | export interface ListActions { 13 | list(options?: QueryOptions): Promise 14 | load(id: string): Promise 15 | select(list: TodoList): Promise 16 | save(list: TodoList): Promise 17 | remove(id: string): Promise 18 | } 19 | 20 | export const list = (options?: QueryOptions): ActionMethod => async (dispatch: Dispatch) => { 21 | const lists = await listService.getList(options); 22 | 23 | dispatch(listListsAction(lists)); 24 | 25 | return lists; 26 | } 27 | 28 | export const select = (list: TodoList): ActionMethod => (dispatch: Dispatch) => { 29 | dispatch(selectListAction(list)); 30 | 31 | return Promise.resolve(list); 32 | } 33 | 34 | export const load = (id: string): ActionMethod => async (dispatch: Dispatch) => { 35 | const list = await listService.get(id); 36 | 37 | dispatch(loadListAction(list)); 38 | 39 | return list; 40 | } 41 | 42 | export const save = (list: TodoList): ActionMethod => async (dispatch: Dispatch) => { 43 | const newList = await listService.save(list); 44 | 45 | dispatch(saveListAction(newList)); 46 | 47 | trackEvent(ActionTypes.SAVE_TODO_LIST.toString()); 48 | 49 | return newList; 50 | } 51 | 52 | export const remove = (id: string): ActionMethod => async (dispatch: Dispatch) => { 53 | await listService.delete(id); 54 | 55 | dispatch(deleteListAction(id)); 56 | } 57 | 58 | export interface ListListsAction extends PayloadAction { 59 | type: ActionTypes.LOAD_TODO_LISTS 60 | } 61 | 62 | export interface SelectListAction extends PayloadAction { 63 | type: ActionTypes.SELECT_TODO_LIST 64 | } 65 | 66 | export interface LoadListAction extends PayloadAction { 67 | type: ActionTypes.LOAD_TODO_LIST 68 | } 69 | 70 | export interface SaveListAction extends PayloadAction { 71 | type: ActionTypes.SAVE_TODO_LIST 72 | } 73 | 74 | export interface DeleteListAction extends PayloadAction { 75 | type: ActionTypes.DELETE_TODO_LIST 76 | } 77 | 78 | const listListsAction = createPayloadAction(ActionTypes.LOAD_TODO_LISTS); 79 | const selectListAction = createPayloadAction(ActionTypes.SELECT_TODO_LIST); 80 | const loadListAction = createPayloadAction(ActionTypes.LOAD_TODO_LIST); 81 | const saveListAction = createPayloadAction(ActionTypes.SAVE_TODO_LIST); 82 | const deleteListAction = createPayloadAction(ActionTypes.DELETE_TODO_LIST); 83 | -------------------------------------------------------------------------------- /src/web/src/components/telemetry.tsx: -------------------------------------------------------------------------------- 1 | import { FC, ReactElement, useEffect, PropsWithChildren } from 'react'; 2 | import { TelemetryProvider } from './telemetryContext'; 3 | import { reactPlugin, getApplicationInsights } from '../services/telemetryService'; 4 | 5 | type TelemetryProps = PropsWithChildren; 6 | 7 | const Telemetry: FC = (props: TelemetryProps): ReactElement => { 8 | 9 | useEffect(() => { 10 | getApplicationInsights(); 11 | }, []); 12 | 13 | return ( 14 | 15 | {props.children} 16 | 17 | ); 18 | } 19 | 20 | export default Telemetry; 21 | -------------------------------------------------------------------------------- /src/web/src/components/telemetryContext.ts: -------------------------------------------------------------------------------- 1 | import { createContext } from 'react'; 2 | import { reactPlugin } from '../services/telemetryService'; 3 | 4 | const TelemetryContext = createContext(reactPlugin); 5 | 6 | export const TelemetryProvider = TelemetryContext.Provider; 7 | export const TelemetryConsumer = TelemetryContext.Consumer; 8 | export default TelemetryContext; 9 | -------------------------------------------------------------------------------- /src/web/src/components/telemetryWithAppInsights.tsx: -------------------------------------------------------------------------------- 1 | import { ComponentType, ComponentClass } from 'react'; 2 | import { reactPlugin } from '../services/telemetryService'; 3 | import { withAITracking } from '@microsoft/applicationinsights-react-js'; 4 | 5 | 6 | const withApplicationInsights = (component: ComponentType, componentName: string): ComponentClass, unknown> => withAITracking(reactPlugin, component, componentName); 7 | 8 | export default withApplicationInsights; 9 | -------------------------------------------------------------------------------- /src/web/src/components/todoContext.ts: -------------------------------------------------------------------------------- 1 | import { createContext } from "react"; 2 | import { AppContext, getDefaultState } from "../models/applicationState"; 3 | 4 | const initialState = getDefaultState(); 5 | const dispatch = () => { return }; 6 | 7 | export const TodoContext = createContext({ state: initialState, dispatch: dispatch }); -------------------------------------------------------------------------------- /src/web/src/components/todoItemDetailPane.tsx: -------------------------------------------------------------------------------- 1 | import { Text, DatePicker, Stack, TextField, PrimaryButton, DefaultButton, Dropdown, IDropdownOption, FontIcon } from '@fluentui/react'; 2 | import { useEffect, useState, FC, ReactElement, MouseEvent, FormEvent } from 'react'; 3 | import { TodoItem, TodoItemState } from '../models'; 4 | import { stackGaps, stackItemMargin, stackItemPadding, titleStackStyles } from '../ux/styles'; 5 | 6 | interface TodoItemDetailPaneProps { 7 | item?: TodoItem; 8 | onEdit: (item: TodoItem) => void 9 | onCancel: () => void 10 | } 11 | 12 | export const TodoItemDetailPane: FC = (props: TodoItemDetailPaneProps): ReactElement => { 13 | const [name, setName] = useState(props.item?.name || ''); 14 | const [description, setDescription] = useState(props.item?.description); 15 | const [dueDate, setDueDate] = useState(props.item?.dueDate); 16 | const [state, setState] = useState(props.item?.state || TodoItemState.Todo); 17 | 18 | useEffect(() => { 19 | setName(props.item?.name || ''); 20 | setDescription(props.item?.description); 21 | setDueDate(props.item?.dueDate ? new Date(props.item?.dueDate) : undefined); 22 | setState(props.item?.state || TodoItemState.Todo); 23 | }, [props.item]); 24 | 25 | const saveTodoItem = (evt: MouseEvent) => { 26 | evt.preventDefault(); 27 | 28 | if (!props.item?.id) { 29 | return; 30 | } 31 | 32 | const todoItem: TodoItem = { 33 | id: props.item.id, 34 | listId: props.item.listId, 35 | name: name, 36 | description: description, 37 | dueDate: dueDate, 38 | state: state, 39 | }; 40 | 41 | props.onEdit(todoItem); 42 | }; 43 | 44 | const cancelEdit = () => { 45 | props.onCancel(); 46 | } 47 | 48 | const onStateChange = (_evt: FormEvent, value?: IDropdownOption) => { 49 | if (value) { 50 | setState(value.key as TodoItemState); 51 | } 52 | } 53 | 54 | const onDueDateChange = (date: Date | null | undefined) => { 55 | setDueDate(date || undefined); 56 | } 57 | 58 | const todoStateOptions: IDropdownOption[] = [ 59 | { key: TodoItemState.Todo, text: 'To Do' }, 60 | { key: TodoItemState.InProgress, text: 'In Progress' }, 61 | { key: TodoItemState.Done, text: 'Done' }, 62 | ]; 63 | 64 | return ( 65 | 66 | {props.item && 67 | <> 68 | 69 | {name} 70 | {description} 71 | 72 | 73 | setName(value || '')} /> 74 | setDescription(value)} /> 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | } 86 | {!props.item && 87 | 88 | 89 | Select an item to edit 90 | } 91 | 92 | ); 93 | } 94 | 95 | export default TodoItemDetailPane; -------------------------------------------------------------------------------- /src/web/src/components/todoItemListPane.tsx: -------------------------------------------------------------------------------- 1 | import { CommandBar, DetailsList, DetailsListLayoutMode, IStackStyles, Selection, Label, Spinner, SpinnerSize, Stack, IIconProps, SearchBox, Text, IGroup, IColumn, MarqueeSelection, FontIcon, IObjectWithKey, CheckboxVisibility, IDetailsGroupRenderProps, getTheme } from '@fluentui/react'; 2 | import { ReactElement, useEffect, useState, FormEvent, FC } from 'react'; 3 | import { useNavigate } from 'react-router'; 4 | import { TodoItem, TodoItemState, TodoList } from '../models'; 5 | import { stackItemPadding } from '../ux/styles'; 6 | 7 | interface TodoItemListPaneProps { 8 | list?: TodoList 9 | items?: TodoItem[] 10 | selectedItem?: TodoItem; 11 | disabled: boolean 12 | onCreated: (item: TodoItem) => void 13 | onDelete: (item: TodoItem) => void 14 | onComplete: (item: TodoItem) => void 15 | onSelect: (item?: TodoItem) => void 16 | } 17 | 18 | interface TodoDisplayItem extends IObjectWithKey { 19 | id?: string 20 | listId: string 21 | name: string 22 | state: TodoItemState 23 | description?: string 24 | dueDate: Date | string 25 | completedDate: Date | string 26 | data: TodoItem 27 | createdDate?: Date 28 | updatedDate?: Date 29 | } 30 | 31 | const addIconProps: IIconProps = { 32 | iconName: 'Add', 33 | styles: { 34 | root: { 35 | } 36 | } 37 | }; 38 | 39 | const createListItems = (items: TodoItem[]): TodoDisplayItem[] => { 40 | return items.map(item => ({ 41 | ...item, 42 | key: item.id, 43 | dueDate: item.dueDate ? new Date(item.dueDate).toDateString() : 'None', 44 | completedDate: item.completedDate ? new Date(item.completedDate).toDateString() : 'N/A', 45 | data: item 46 | })); 47 | }; 48 | 49 | const stackStyles: IStackStyles = { 50 | root: { 51 | alignItems: 'center' 52 | } 53 | } 54 | 55 | const TodoItemListPane: FC = (props: TodoItemListPaneProps): ReactElement => { 56 | const theme = getTheme(); 57 | const navigate = useNavigate(); 58 | const [newItemName, setNewItemName] = useState(''); 59 | const [items, setItems] = useState(createListItems(props.items || [])); 60 | const [selectedItems, setSelectedItems] = useState([]); 61 | const [isDoneCategoryCollapsed, setIsDoneCategoryCollapsed] = useState(true); 62 | 63 | // eslint-disable-next-line react-hooks/exhaustive-deps 64 | const selection = new Selection({ 65 | onSelectionChanged: () => { 66 | const selectedItems = selection.getSelection().map(item => (item as TodoDisplayItem).data); 67 | setSelectedItems(selectedItems); 68 | } 69 | }); 70 | 71 | // Handle list changed 72 | useEffect(() => { 73 | setIsDoneCategoryCollapsed(true); 74 | setSelectedItems([]); 75 | }, [props.list]); 76 | 77 | // Handle items changed 78 | useEffect(() => { 79 | const sortedItems = (props.items || []).sort((a, b) => { 80 | if (a.state === b.state) { 81 | return a.name < b.name ? -1 : 1; 82 | } 83 | 84 | return a.state < b.state ? -1 : 1; 85 | }) 86 | setItems(createListItems(sortedItems || [])); 87 | }, [props.items]); 88 | 89 | // Handle selected item changed 90 | useEffect(() => { 91 | if (items.length > 0 && props.selectedItem?.id) { 92 | selection.setKeySelected(props.selectedItem.id, true, true); 93 | } 94 | 95 | const doneItems = selectedItems.filter(i => i.state === TodoItemState.Done); 96 | if (doneItems.length > 0) { 97 | setIsDoneCategoryCollapsed(false); 98 | } 99 | 100 | }, [items.length, props.selectedItem, selectedItems, selection]) 101 | 102 | const groups: IGroup[] = [ 103 | { 104 | key: TodoItemState.Todo, 105 | name: 'Todo', 106 | count: items.filter(i => i.state === TodoItemState.Todo).length, 107 | startIndex: items.findIndex(i => i.state === TodoItemState.Todo), 108 | }, 109 | { 110 | key: TodoItemState.InProgress, 111 | name: 'In Progress', 112 | count: items.filter(i => i.state === TodoItemState.InProgress).length, 113 | startIndex: items.findIndex(i => i.state === TodoItemState.InProgress) 114 | }, 115 | { 116 | key: TodoItemState.Done, 117 | name: 'Done', 118 | count: items.filter(i => i.state === TodoItemState.Done).length, 119 | startIndex: items.findIndex(i => i.state === TodoItemState.Done), 120 | isCollapsed: isDoneCategoryCollapsed 121 | }, 122 | ] 123 | 124 | const onFormSubmit = (evt: FormEvent) => { 125 | evt.preventDefault(); 126 | 127 | if (newItemName && props.onCreated) { 128 | const item: TodoItem = { 129 | name: newItemName, 130 | listId: props.list?.id || '', 131 | state: TodoItemState.Todo, 132 | } 133 | props.onCreated(item); 134 | setNewItemName(''); 135 | } 136 | } 137 | 138 | const onNewItemChanged = (_evt?: FormEvent, value?: string) => { 139 | setNewItemName(value || ''); 140 | } 141 | 142 | const selectItem = (item: TodoDisplayItem) => { 143 | navigate(`/lists/${item.data.listId}/items/${item.data.id}`); 144 | } 145 | 146 | const completeItems = () => { 147 | selectedItems.map(item => props.onComplete(item)); 148 | } 149 | 150 | const deleteItems = () => { 151 | selectedItems.map(item => props.onDelete(item)); 152 | } 153 | 154 | const columns: IColumn[] = [ 155 | { key: 'name', name: 'Name', fieldName: 'name', minWidth: 100 }, 156 | { key: 'dueDate', name: 'Due', fieldName: 'dueDate', minWidth: 100 }, 157 | { key: 'completedDate', name: 'Completed', fieldName: 'completedDate', minWidth: 100 }, 158 | ]; 159 | 160 | const groupRenderProps: IDetailsGroupRenderProps = { 161 | headerProps: { 162 | styles: { 163 | groupHeaderContainer: { 164 | backgroundColor: theme.palette.neutralPrimary 165 | } 166 | } 167 | } 168 | } 169 | 170 | const renderItemColumn = (item: TodoDisplayItem, _index?: number, column?: IColumn) => { 171 | const fieldContent = item[column?.fieldName as keyof TodoDisplayItem] as string; 172 | 173 | switch (column?.key) { 174 | case "name": 175 | return ( 176 | <> 177 | {item.name} 178 | {item.description && 179 | <> 180 | 181 | {item.description} 182 | 183 | } 184 | 185 | ); 186 | default: 187 | return ({fieldContent}) 188 | } 189 | } 190 | 191 | return ( 192 | 193 | 194 |
195 | 196 | 197 | 198 | 199 | 200 | { completeItems() } 208 | }, 209 | { 210 | key: 'delete', 211 | text: 'Delete', 212 | disabled: props.disabled, 213 | iconProps: { iconName: 'Delete' }, 214 | onClick: () => { deleteItems() } 215 | } 216 | ]} 217 | ariaLabel="Todo actions" /> 218 | 219 | 220 |
221 |
222 | {items.length > 0 && 223 | 224 | 225 | 240 | 241 | 242 | } 243 | {!props.items && 244 | 245 | 246 | 247 | 248 | } 249 | {props.items && items.length === 0 && 250 | 251 | This list is empty. 252 | 253 | } 254 |
255 | ); 256 | }; 257 | 258 | export default TodoItemListPane; -------------------------------------------------------------------------------- /src/web/src/components/todoListMenu.tsx: -------------------------------------------------------------------------------- 1 | import { IIconProps, INavLink, INavLinkGroup, Nav, Stack, TextField } from '@fluentui/react'; 2 | import { FC, ReactElement, useState, FormEvent, MouseEvent } from 'react'; 3 | import { useNavigate } from 'react-router'; 4 | import { TodoList } from '../models/todoList'; 5 | import { stackItemPadding } from '../ux/styles'; 6 | 7 | interface TodoListMenuProps { 8 | selectedList?: TodoList 9 | lists?: TodoList[] 10 | onCreate: (list: TodoList) => void 11 | } 12 | 13 | const iconProps: IIconProps = { 14 | iconName: 'AddToShoppingList' 15 | } 16 | 17 | const TodoListMenu: FC = (props: TodoListMenuProps): ReactElement => { 18 | const navigate = useNavigate(); 19 | const [newListName, setNewListName] = useState(''); 20 | 21 | const onNavLinkClick = (evt?: MouseEvent, item?: INavLink) => { 22 | evt?.preventDefault(); 23 | 24 | if (!item) { 25 | return; 26 | } 27 | 28 | navigate(`/lists/${item.key}`); 29 | } 30 | 31 | const createNavGroups = (lists: TodoList[]): INavLinkGroup[] => { 32 | const links = lists.map(list => ({ 33 | key: list.id, 34 | name: list.name, 35 | url: `/lists/${list.id}`, 36 | links: [], 37 | isExpanded: props.selectedList ? list.id === props.selectedList.id : false 38 | })); 39 | 40 | return [{ 41 | links: links 42 | }] 43 | } 44 | 45 | const onNewListNameChange = (_evt: FormEvent, value?: string) => { 46 | setNewListName(value || ''); 47 | } 48 | 49 | const onFormSubmit = async (evt: FormEvent) => { 50 | evt.preventDefault(); 51 | 52 | if (newListName) { 53 | const list: TodoList = { 54 | name: newListName 55 | }; 56 | 57 | props.onCreate(list); 58 | setNewListName(''); 59 | } 60 | } 61 | 62 | return ( 63 | 64 | 65 |