From 3aec0c8656e59935bc0629f565828be8dc48a249 Mon Sep 17 00:00:00 2001 From: Marcelo Mizuno Date: Thu, 27 Mar 2025 21:35:25 -0300 Subject: [PATCH 01/19] =?UTF-8?q?Configura=C3=A7=C3=A3o=20de=20credenciais?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env | 10 +++++----- docker-compose.yml | 4 ++-- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.env b/.env index 1d44286e25..84782451fa 100644 --- a/.env +++ b/.env @@ -18,9 +18,9 @@ STACK_NAME=full-stack-fastapi-project # Backend BACKEND_CORS_ORIGINS="http://localhost,http://localhost:5173,https://localhost,https://localhost:5173,http://localhost.tiangolo.com" -SECRET_KEY=changethis -FIRST_SUPERUSER=admin@example.com -FIRST_SUPERUSER_PASSWORD=changethis +SECRET_KEY=kjadhflkafaskjdfhakjfsd +FIRST_SUPERUSER=marcelomizuno@me.com +FIRST_SUPERUSER_PASSWORD=admin123 # Emails SMTP_HOST= @@ -32,11 +32,11 @@ SMTP_SSL=False SMTP_PORT=587 # Postgres -POSTGRES_SERVER=localhost +POSTGRES_SERVER=db POSTGRES_PORT=5432 POSTGRES_DB=app POSTGRES_USER=postgres -POSTGRES_PASSWORD=changethis +POSTGRES_PASSWORD=univesp SENTRY_DSN= diff --git a/docker-compose.yml b/docker-compose.yml index c92d5d4451..e292b55d52 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -59,7 +59,7 @@ services: environment: - DOMAIN=${DOMAIN} - FRONTEND_HOST=${FRONTEND_HOST?Variable not set} - - ENVIRONMENT=${ENVIRONMENT} + - ENVIRONMENT=local - BACKEND_CORS_ORIGINS=${BACKEND_CORS_ORIGINS} - SECRET_KEY=${SECRET_KEY?Variable not set} - FIRST_SUPERUSER=${FIRST_SUPERUSER?Variable not set} @@ -92,7 +92,7 @@ services: environment: - DOMAIN=${DOMAIN} - FRONTEND_HOST=${FRONTEND_HOST?Variable not set} - - ENVIRONMENT=${ENVIRONMENT} + - ENVIRONMENT=local - BACKEND_CORS_ORIGINS=${BACKEND_CORS_ORIGINS} - SECRET_KEY=${SECRET_KEY?Variable not set} - FIRST_SUPERUSER=${FIRST_SUPERUSER?Variable not set} From c08554b58e36e9a2a7ca8458e53f5bdd5bbb7003 Mon Sep 17 00:00:00 2001 From: Fernanda Date: Mon, 31 Mar 2025 21:03:03 -0300 Subject: [PATCH 02/19] First commit ticket-page-test ( conf. routes, SidebarItems and tickets page). --- frontend/package-lock.json | 1 + frontend/package.json | 9 ++++- .../src/components/Common/SidebarItems.tsx | 5 ++- frontend/src/routeTree.gen.ts | 11 ++++++ frontend/src/routes/_layout/tickets.tsx | 34 +++++++++++++++++++ 5 files changed, 58 insertions(+), 2 deletions(-) create mode 100644 frontend/src/routes/_layout/tickets.tsx diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 275b7a6d71..edf47f8180 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -7,6 +7,7 @@ "": { "name": "frontend", "version": "0.0.0", + "license": "ISC", "dependencies": { "@chakra-ui/react": "^3.8.0", "@emotion/react": "^11.14.0", diff --git a/frontend/package.json b/frontend/package.json index 4452d0edae..6a619701cf 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -38,5 +38,12 @@ "dotenv": "^16.4.5", "typescript": "^5.2.2", "vite": "^5.4.14" - } + }, + "description": "The frontend is built with [Vite](https://vitejs.dev/), [React](https://reactjs.org/), [TypeScript](https://www.typescriptlang.org/), [TanStack Query](https://tanstack.com/query), [TanStack Router](https://tanstack.com/router) and [Chakra UI](https://chakra-ui.com/).", + "main": "index.js", + "directories": { + "test": "tests" + }, + "author": "", + "license": "ISC" } diff --git a/frontend/src/components/Common/SidebarItems.tsx b/frontend/src/components/Common/SidebarItems.tsx index 13f71495f5..9afb4c2cf0 100644 --- a/frontend/src/components/Common/SidebarItems.tsx +++ b/frontend/src/components/Common/SidebarItems.tsx @@ -5,11 +5,14 @@ import { FiBriefcase, FiHome, FiSettings, FiUsers } from "react-icons/fi" import type { IconType } from "react-icons/lib" import type { UserPublic } from "@/client" +import { BsTicketDetailedFill } from "react-icons/bs" const items = [ + //Adicionando mais menus ( ticket), e renomeando seções { icon: FiHome, title: "Dashboard", path: "/" }, { icon: FiBriefcase, title: "Items", path: "/items" }, - { icon: FiSettings, title: "User Settings", path: "/settings" }, + { icon: FiSettings, title: "Configurações do Usuário", path: "/settings" }, + { icon: BsTicketDetailedFill, title: "Tickets", path: "/tickets" }, ] interface SidebarItemsProps { diff --git a/frontend/src/routeTree.gen.ts b/frontend/src/routeTree.gen.ts index 0e78c9ba20..4692f575d9 100644 --- a/frontend/src/routeTree.gen.ts +++ b/frontend/src/routeTree.gen.ts @@ -17,6 +17,7 @@ import { Route as RecoverPasswordImport } from './routes/recover-password' import { Route as LoginImport } from './routes/login' import { Route as LayoutImport } from './routes/_layout' import { Route as LayoutIndexImport } from './routes/_layout/index' +import { Route as LayoutTicketsImport } from './routes/_layout/tickets' import { Route as LayoutSettingsImport } from './routes/_layout/settings' import { Route as LayoutItemsImport } from './routes/_layout/items' import { Route as LayoutAdminImport } from './routes/_layout/admin' @@ -53,6 +54,11 @@ const LayoutIndexRoute = LayoutIndexImport.update({ getParentRoute: () => LayoutRoute, } as any) +const LayoutTicketsRoute = LayoutTicketsImport.update({ + path: '/tickets', + getParentRoute: () => LayoutRoute, +} as any) + const LayoutSettingsRoute = LayoutSettingsImport.update({ path: '/settings', getParentRoute: () => LayoutRoute, @@ -104,6 +110,10 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof LayoutSettingsImport parentRoute: typeof LayoutImport } + '/_layout/tickets': { + preLoaderRoute: typeof LayoutTicketsImport + parentRoute: typeof LayoutImport + } '/_layout/': { preLoaderRoute: typeof LayoutIndexImport parentRoute: typeof LayoutImport @@ -118,6 +128,7 @@ export const routeTree = rootRoute.addChildren([ LayoutAdminRoute, LayoutItemsRoute, LayoutSettingsRoute, + LayoutTicketsRoute, LayoutIndexRoute, ]), LoginRoute, diff --git a/frontend/src/routes/_layout/tickets.tsx b/frontend/src/routes/_layout/tickets.tsx new file mode 100644 index 0000000000..f4b0d2960a --- /dev/null +++ b/frontend/src/routes/_layout/tickets.tsx @@ -0,0 +1,34 @@ +import { Box, Container, Text } from "@chakra-ui/react" +import { createFileRoute } from "@tanstack/react-router" + +//import useAuth from "@/hooks/useAuth" + +export const Route = createFileRoute("/_layout/tickets")({ + component: Tickets, +}) + +function Tickets() { + //const { user: currentUser } = useAuth() + + return ( + <> + + + +
    +
      Página de Listagem de Tickets
    +
  • - Exibição dos tickets cadastrados em formato de tabela ou lista com informações resumidas (título, status, prioridade, data de criação)
  • +
  • - Campo de busca para filtrar por palavra-chave
  • +
  • - Filtros para status, data, prioridade e categoria
  • +
  • - Opções de ordenação e paginação para facilitar a navegação
  • +
+
+ +
+
+ + ) +} + + + From 9a367357d2125754f8afada0631970ff3bec6618 Mon Sep 17 00:00:00 2001 From: Fernanda Date: Tue, 1 Apr 2025 14:11:37 -0300 Subject: [PATCH 03/19] Update test - Adicionando um
  • na pagina de tickets usando a branch master. --- frontend/src/routes/_layout/tickets.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/src/routes/_layout/tickets.tsx b/frontend/src/routes/_layout/tickets.tsx index f4b0d2960a..b4cf70adfb 100644 --- a/frontend/src/routes/_layout/tickets.tsx +++ b/frontend/src/routes/_layout/tickets.tsx @@ -17,6 +17,7 @@ function Tickets() {
        Página de Listagem de Tickets
      +
    • Requisitos:
    • - Exibição dos tickets cadastrados em formato de tabela ou lista com informações resumidas (título, status, prioridade, data de criação)
    • - Campo de busca para filtrar por palavra-chave
    • - Filtros para status, data, prioridade e categoria
    • From c929cc01254cdc04270e51eb0674168483b055eb Mon Sep 17 00:00:00 2001 From: Marcelo Mizuno Date: Sun, 6 Apr 2025 22:29:24 -0300 Subject: [PATCH 04/19] Added tickets --- backend/README.bak.md | 172 +++++++++++++++++ backend/README.md | 299 +++++++++++++----------------- backend/app/api/main.py | 3 +- backend/app/api/routes/tickets.py | 172 +++++++++++++++++ backend/app/models.py | 102 ++++++++++ 5 files changed, 575 insertions(+), 173 deletions(-) create mode 100644 backend/README.bak.md create mode 100644 backend/app/api/routes/tickets.py diff --git a/backend/README.bak.md b/backend/README.bak.md new file mode 100644 index 0000000000..17210a2f2c --- /dev/null +++ b/backend/README.bak.md @@ -0,0 +1,172 @@ +# FastAPI Project - Backend + +## Requirements + +* [Docker](https://www.docker.com/). +* [uv](https://docs.astral.sh/uv/) for Python package and environment management. + +## Docker Compose + +Start the local development environment with Docker Compose following the guide in [../development.md](../development.md). + +## General Workflow + +By default, the dependencies are managed with [uv](https://docs.astral.sh/uv/), go there and install it. + +From `./backend/` you can install all the dependencies with: + +```console +$ uv sync +``` + +Then you can activate the virtual environment with: + +```console +$ source .venv/bin/activate +``` + +Make sure your editor is using the correct Python virtual environment, with the interpreter at `backend/.venv/bin/python`. + +Modify or add SQLModel models for data and SQL tables in `./backend/app/models.py`, API endpoints in `./backend/app/api/`, CRUD (Create, Read, Update, Delete) utils in `./backend/app/crud.py`. + +## VS Code + +There are already configurations in place to run the backend through the VS Code debugger, so that you can use breakpoints, pause and explore variables, etc. + +The setup is also already configured so you can run the tests through the VS Code Python tests tab. + +## Docker Compose Override + +During development, you can change Docker Compose settings that will only affect the local development environment in the file `docker-compose.override.yml`. + +The changes to that file only affect the local development environment, not the production environment. So, you can add "temporary" changes that help the development workflow. + +For example, the directory with the backend code is synchronized in the Docker container, copying the code you change live to the directory inside the container. That allows you to test your changes right away, without having to build the Docker image again. It should only be done during development, for production, you should build the Docker image with a recent version of the backend code. But during development, it allows you to iterate very fast. + +There is also a command override that runs `fastapi run --reload` instead of the default `fastapi run`. It starts a single server process (instead of multiple, as would be for production) and reloads the process whenever the code changes. Have in mind that if you have a syntax error and save the Python file, it will break and exit, and the container will stop. After that, you can restart the container by fixing the error and running again: + +```console +$ docker compose watch +``` + +There is also a commented out `command` override, you can uncomment it and comment the default one. It makes the backend container run a process that does "nothing", but keeps the container alive. That allows you to get inside your running container and execute commands inside, for example a Python interpreter to test installed dependencies, or start the development server that reloads when it detects changes. + +To get inside the container with a `bash` session you can start the stack with: + +```console +$ docker compose watch +``` + +and then in another terminal, `exec` inside the running container: + +```console +$ docker compose exec backend bash +``` + +You should see an output like: + +```console +root@7f2607af31c3:/app# +``` + +that means that you are in a `bash` session inside your container, as a `root` user, under the `/app` directory, this directory has another directory called "app" inside, that's where your code lives inside the container: `/app/app`. + +There you can use the `fastapi run --reload` command to run the debug live reloading server. + +```console +$ fastapi run --reload app/main.py +``` + +...it will look like: + +```console +root@7f2607af31c3:/app# fastapi run --reload app/main.py +``` + +and then hit enter. That runs the live reloading server that auto reloads when it detects code changes. + +Nevertheless, if it doesn't detect a change but a syntax error, it will just stop with an error. But as the container is still alive and you are in a Bash session, you can quickly restart it after fixing the error, running the same command ("up arrow" and "Enter"). + +...this previous detail is what makes it useful to have the container alive doing nothing and then, in a Bash session, make it run the live reload server. + +## Backend tests + +To test the backend run: + +```console +$ bash ./scripts/test.sh +``` + +The tests run with Pytest, modify and add tests to `./backend/app/tests/`. + +If you use GitHub Actions the tests will run automatically. + +### Test running stack + +If your stack is already up and you just want to run the tests, you can use: + +```bash +docker compose exec backend bash scripts/tests-start.sh +``` + +That `/app/scripts/tests-start.sh` script just calls `pytest` after making sure that the rest of the stack is running. If you need to pass extra arguments to `pytest`, you can pass them to that command and they will be forwarded. + +For example, to stop on first error: + +```bash +docker compose exec backend bash scripts/tests-start.sh -x +``` + +### Test Coverage + +When the tests are run, a file `htmlcov/index.html` is generated, you can open it in your browser to see the coverage of the tests. + +## Migrations + +As during local development your app directory is mounted as a volume inside the container, you can also run the migrations with `alembic` commands inside the container and the migration code will be in your app directory (instead of being only inside the container). So you can add it to your git repository. + +Make sure you create a "revision" of your models and that you "upgrade" your database with that revision every time you change them. As this is what will update the tables in your database. Otherwise, your application will have errors. + +* Start an interactive session in the backend container: + +```console +$ docker compose exec backend bash +``` + +* Alembic is already configured to import your SQLModel models from `./backend/app/models.py`. + +* After changing a model (for example, adding a column), inside the container, create a revision, e.g.: + +```console +$ alembic revision --autogenerate -m "Add column last_name to User model" +``` + +* Commit to the git repository the files generated in the alembic directory. + +* After creating the revision, run the migration in the database (this is what will actually change the database): + +```console +$ alembic upgrade head +``` + +If you don't want to use migrations at all, uncomment the lines in the file at `./backend/app/core/db.py` that end in: + +```python +SQLModel.metadata.create_all(engine) +``` + +and comment the line in the file `scripts/prestart.sh` that contains: + +```console +$ alembic upgrade head +``` + +If you don't want to start with the default models and want to remove them / modify them, from the beginning, without having any previous revision, you can remove the revision files (`.py` Python files) under `./backend/app/alembic/versions/`. And then create a first migration as described above. + +## Email Templates + +The email templates are in `./backend/app/email-templates/`. Here, there are two directories: `build` and `src`. The `src` directory contains the source files that are used to build the final email templates. The `build` directory contains the final email templates that are used by the application. + +Before continuing, ensure you have the [MJML extension](https://marketplace.visualstudio.com/items?itemName=attilabuti.vscode-mjml) installed in your VS Code. + +Once you have the MJML extension installed, you can create a new email template in the `src` directory. After creating the new email template and with the `.mjml` file open in your editor, open the command palette with `Ctrl+Shift+P` and search for `MJML: Export to HTML`. This will convert the `.mjml` file to a `.html` file and now you can save it in the build directory. diff --git a/backend/README.md b/backend/README.md index 17210a2f2c..601009752a 100644 --- a/backend/README.md +++ b/backend/README.md @@ -1,172 +1,127 @@ -# FastAPI Project - Backend - -## Requirements - -* [Docker](https://www.docker.com/). -* [uv](https://docs.astral.sh/uv/) for Python package and environment management. - -## Docker Compose - -Start the local development environment with Docker Compose following the guide in [../development.md](../development.md). - -## General Workflow - -By default, the dependencies are managed with [uv](https://docs.astral.sh/uv/), go there and install it. - -From `./backend/` you can install all the dependencies with: - -```console -$ uv sync -``` - -Then you can activate the virtual environment with: - -```console -$ source .venv/bin/activate -``` - -Make sure your editor is using the correct Python virtual environment, with the interpreter at `backend/.venv/bin/python`. - -Modify or add SQLModel models for data and SQL tables in `./backend/app/models.py`, API endpoints in `./backend/app/api/`, CRUD (Create, Read, Update, Delete) utils in `./backend/app/crud.py`. - -## VS Code - -There are already configurations in place to run the backend through the VS Code debugger, so that you can use breakpoints, pause and explore variables, etc. - -The setup is also already configured so you can run the tests through the VS Code Python tests tab. - -## Docker Compose Override - -During development, you can change Docker Compose settings that will only affect the local development environment in the file `docker-compose.override.yml`. - -The changes to that file only affect the local development environment, not the production environment. So, you can add "temporary" changes that help the development workflow. - -For example, the directory with the backend code is synchronized in the Docker container, copying the code you change live to the directory inside the container. That allows you to test your changes right away, without having to build the Docker image again. It should only be done during development, for production, you should build the Docker image with a recent version of the backend code. But during development, it allows you to iterate very fast. - -There is also a command override that runs `fastapi run --reload` instead of the default `fastapi run`. It starts a single server process (instead of multiple, as would be for production) and reloads the process whenever the code changes. Have in mind that if you have a syntax error and save the Python file, it will break and exit, and the container will stop. After that, you can restart the container by fixing the error and running again: - -```console -$ docker compose watch -``` - -There is also a commented out `command` override, you can uncomment it and comment the default one. It makes the backend container run a process that does "nothing", but keeps the container alive. That allows you to get inside your running container and execute commands inside, for example a Python interpreter to test installed dependencies, or start the development server that reloads when it detects changes. - -To get inside the container with a `bash` session you can start the stack with: - -```console -$ docker compose watch -``` - -and then in another terminal, `exec` inside the running container: - -```console -$ docker compose exec backend bash -``` - -You should see an output like: - -```console -root@7f2607af31c3:/app# -``` - -that means that you are in a `bash` session inside your container, as a `root` user, under the `/app` directory, this directory has another directory called "app" inside, that's where your code lives inside the container: `/app/app`. - -There you can use the `fastapi run --reload` command to run the debug live reloading server. - -```console -$ fastapi run --reload app/main.py -``` - -...it will look like: - -```console -root@7f2607af31c3:/app# fastapi run --reload app/main.py -``` - -and then hit enter. That runs the live reloading server that auto reloads when it detects code changes. - -Nevertheless, if it doesn't detect a change but a syntax error, it will just stop with an error. But as the container is still alive and you are in a Bash session, you can quickly restart it after fixing the error, running the same command ("up arrow" and "Enter"). - -...this previous detail is what makes it useful to have the container alive doing nothing and then, in a Bash session, make it run the live reload server. - -## Backend tests - -To test the backend run: - -```console -$ bash ./scripts/test.sh -``` - -The tests run with Pytest, modify and add tests to `./backend/app/tests/`. - -If you use GitHub Actions the tests will run automatically. - -### Test running stack - -If your stack is already up and you just want to run the tests, you can use: - -```bash -docker compose exec backend bash scripts/tests-start.sh -``` - -That `/app/scripts/tests-start.sh` script just calls `pytest` after making sure that the rest of the stack is running. If you need to pass extra arguments to `pytest`, you can pass them to that command and they will be forwarded. - -For example, to stop on first error: - -```bash -docker compose exec backend bash scripts/tests-start.sh -x -``` - -### Test Coverage - -When the tests are run, a file `htmlcov/index.html` is generated, you can open it in your browser to see the coverage of the tests. - -## Migrations - -As during local development your app directory is mounted as a volume inside the container, you can also run the migrations with `alembic` commands inside the container and the migration code will be in your app directory (instead of being only inside the container). So you can add it to your git repository. - -Make sure you create a "revision" of your models and that you "upgrade" your database with that revision every time you change them. As this is what will update the tables in your database. Otherwise, your application will have errors. - -* Start an interactive session in the backend container: - -```console -$ docker compose exec backend bash -``` - -* Alembic is already configured to import your SQLModel models from `./backend/app/models.py`. - -* After changing a model (for example, adding a column), inside the container, create a revision, e.g.: - -```console -$ alembic revision --autogenerate -m "Add column last_name to User model" -``` - -* Commit to the git repository the files generated in the alembic directory. - -* After creating the revision, run the migration in the database (this is what will actually change the database): - -```console -$ alembic upgrade head -``` - -If you don't want to use migrations at all, uncomment the lines in the file at `./backend/app/core/db.py` that end in: - -```python -SQLModel.metadata.create_all(engine) -``` - -and comment the line in the file `scripts/prestart.sh` that contains: - -```console -$ alembic upgrade head -``` - -If you don't want to start with the default models and want to remove them / modify them, from the beginning, without having any previous revision, you can remove the revision files (`.py` Python files) under `./backend/app/alembic/versions/`. And then create a first migration as described above. - -## Email Templates - -The email templates are in `./backend/app/email-templates/`. Here, there are two directories: `build` and `src`. The `src` directory contains the source files that are used to build the final email templates. The `build` directory contains the final email templates that are used by the application. - -Before continuing, ensure you have the [MJML extension](https://marketplace.visualstudio.com/items?itemName=attilabuti.vscode-mjml) installed in your VS Code. - -Once you have the MJML extension installed, you can create a new email template in the `src` directory. After creating the new email template and with the `.mjml` file open in your editor, open the command palette with `Ctrl+Shift+P` and search for `MJML: Export to HTML`. This will convert the `.mjml` file to a `.html` file and now you can save it in the build directory. +# Banco de Dados + + +### Tickets +- **id** +- **title** +- **description** +- **category** (Suporte, Manutenção, Dúvida) +- **priority** (Baixa, Média, Alta) +- **status** (Aberto, Em andamento, Fechado) +- **user_id** +- **created_at** +- **updated_at** + +### Comments +- **id** +- **ticket_id** +- **user_id** +- **comment** +- **created_at** + +--- + +# Endpoints + + +## GET /tickets +- **Descrição**: Listar todos os tickets (com filtros e paginação). +- **Exemplo de Response**: +{ + "tickets": [ + { + "id": 1, + "title": "Erro no sistema", + "status": "Aberto", + "priority": "Alta", + "created_at": "2023-10-01T12:00:00Z" + }, + { + "id": 2, + "title": "Problema com login", + "status": "Em andamento", + "priority": "Média", + "created_at": "2023-10-02T08:30:00Z" + } + ], + "page": 1, + "total": 2 +} + +## GET /tickets/{id} +- **Descrição**: Retorna detalhes de um ticket. +- **Exemplo de Response**: +{ + "id": 1, + "title": "Erro no sistema", + "description": "Detalhes do erro ocorrido...", + "category": "Suporte", + "priority": "Alta", + "status": "Aberto", + "created_at": "2023-10-01T12:00:00Z", + "updated_at": "2023-10-01T12:00:00Z", + "comments": [ + { + "id": 1, + "comment": "Identificado o problema inicial.", + "created_at": "2023-10-01T12:30:00Z" + } + ] +} + +## POST /tickets +- **Descrição**: Criar um novo ticket. +- **Request Body**: +{ + "title": "Novo ticket", + "description": "Detalhes do ticket...", + "category": "Manutenção", + "priority": "Média" +} +- **Exemplo de Response**: +{ + "id": 3, + "title": "Novo ticket", + "description": "Detalhes do ticket...", + "category": "Manutenção", + "priority": "Média", + "status": "Aberto", + "created_at": "2023-10-05T10:00:00Z", + "updated_at": "2023-10-05T10:00:00Z" +} + +## PUT /tickets/{id} +- **Descrição**: Atualizar um ticket existente. +- **Request Body**: +{ + "title": "Ticket atualizado", + "description": "Descrição atualizada...", + "category": "Suporte", + "priority": "Alta", + "status": "Em andamento" +} +- **Exemplo de Response**: +{ + "id": 1, + "title": "Ticket atualizado", + "description": "Descrição atualizada...", + "category": "Suporte", + "priority": "Alta", + "status": "Em andamento", + "created_at": "2023-10-01T12:00:00Z", + "updated_at": "2023-10-05T10:00:00Z" +} + +## POST /tickets/{id}/comments +- **Descrição**: Adicionar comentário a um ticket. +- **Request Body**: +{ + "comment": "Comentário adicionado ao ticket." +} +- **Exemplo de Response**: +{ + "id": 5, + "ticket_id": 1, + "comment": "Comentário adicionado ao ticket.", + "created_at": "2023-10-05T10:30:00Z" +} \ No newline at end of file diff --git a/backend/app/api/main.py b/backend/app/api/main.py index eac18c8e8f..33862ee8b6 100644 --- a/backend/app/api/main.py +++ b/backend/app/api/main.py @@ -1,6 +1,6 @@ from fastapi import APIRouter -from app.api.routes import items, login, private, users, utils +from app.api.routes import items, login, private, users, utils, tickets from app.core.config import settings api_router = APIRouter() @@ -8,6 +8,7 @@ api_router.include_router(users.router) api_router.include_router(utils.router) api_router.include_router(items.router) +api_router.include_router(tickets.router) # Add tickets router if settings.ENVIRONMENT == "local": diff --git a/backend/app/api/routes/tickets.py b/backend/app/api/routes/tickets.py new file mode 100644 index 0000000000..e89114e141 --- /dev/null +++ b/backend/app/api/routes/tickets.py @@ -0,0 +1,172 @@ +import uuid +from typing import Any + +from fastapi import APIRouter, HTTPException, Query +from sqlmodel import func, select + +from app.api.deps import CurrentUser, SessionDep +from app.models import ( + Ticket, + TicketCreate, + TicketUpdate, + TicketPublic, + TicketsPublic, + TicketDetailPublic, + Comment, + CommentCreate, + CommentPublic, + Message, + TicketCategory, + TicketPriority, + TicketStatus +) + +router = APIRouter(prefix="/tickets", tags=["tickets"]) + + +@router.get("/", response_model=TicketsPublic) +def read_tickets( + session: SessionDep, + current_user: CurrentUser, + skip: int = 0, + limit: int = 100, + page: int = Query(1, ge=1), + category: TicketCategory = None, + priority: TicketPriority = None, + status: TicketStatus = None +) -> Any: + """ + Listar todos os tickets (com filtros e paginação). + """ + skip = (page - 1) * limit + + # Base query + query = select(Ticket) + count_query = select(func.count()).select_from(Ticket) + + # Apply filters + if category: + query = query.where(Ticket.category == category) + count_query = count_query.where(Ticket.category == category) + + if priority: + query = query.where(Ticket.priority == priority) + count_query = count_query.where(Ticket.priority == priority) + + if status: + query = query.where(Ticket.status == status) + count_query = count_query.where(Ticket.status == status) + + # Apply user filter if not superuser + if not current_user.is_superuser: + query = query.where(Ticket.user_id == current_user.id) + count_query = count_query.where(Ticket.user_id == current_user.id) + + # Get count and tickets + count = session.exec(count_query).one() + tickets = session.exec(query.offset(skip).limit(limit)).all() + + return TicketsPublic(data=tickets, count=count, page=page) + + +@router.get("/{id}", response_model=TicketDetailPublic) +def read_ticket(session: SessionDep, current_user: CurrentUser, id: uuid.UUID) -> Any: + """ + Retorna detalhes de um ticket. + """ + ticket = session.get(Ticket, id) + if not ticket: + raise HTTPException(status_code=404, detail="Ticket não encontrado") + + if not current_user.is_superuser and (ticket.user_id != current_user.id): + raise HTTPException(status_code=400, detail="Permissões insuficientes") + + return ticket + + +@router.post("/", response_model=TicketPublic) +def create_ticket( + *, session: SessionDep, current_user: CurrentUser, ticket_in: TicketCreate +) -> Any: + """ + Criar um novo ticket. + """ + ticket = Ticket.model_validate(ticket_in, update={"user_id": current_user.id}) + session.add(ticket) + session.commit() + session.refresh(ticket) + return ticket + + +@router.put("/{id}", response_model=TicketPublic) +def update_ticket( + *, + session: SessionDep, + current_user: CurrentUser, + id: uuid.UUID, + ticket_in: TicketUpdate, +) -> Any: + """ + Atualizar um ticket existente. + """ + ticket = session.get(Ticket, id) + if not ticket: + raise HTTPException(status_code=404, detail="Ticket não encontrado") + + if not current_user.is_superuser and (ticket.user_id != current_user.id): + raise HTTPException(status_code=400, detail="Permissões insuficientes") + + update_dict = ticket_in.model_dump(exclude_unset=True) + ticket.sqlmodel_update(update_dict) + ticket.updated_at = func.now() # Update the updated_at field + + session.add(ticket) + session.commit() + session.refresh(ticket) + return ticket + + +@router.delete("/{id}") +def delete_ticket( + session: SessionDep, current_user: CurrentUser, id: uuid.UUID +) -> Message: + """ + Deletar um ticket. + """ + ticket = session.get(Ticket, id) + if not ticket: + raise HTTPException(status_code=404, detail="Ticket não encontrado") + + if not current_user.is_superuser and (ticket.user_id != current_user.id): + raise HTTPException(status_code=400, detail="Permissões insuficientes") + + session.delete(ticket) + session.commit() + return Message(message="Ticket removido com sucesso") + + +@router.post("/{id}/comments", response_model=CommentPublic) +def create_comment( + *, + session: SessionDep, + current_user: CurrentUser, + id: uuid.UUID, + comment_in: CommentCreate, +) -> Any: + """ + Adicionar comentário a um ticket. + """ + ticket = session.get(Ticket, id) + if not ticket: + raise HTTPException(status_code=404, detail="Ticket não encontrado") + + comment = Comment( + **comment_in.model_dump(), + ticket_id=id, + user_id=current_user.id + ) + + session.add(comment) + session.commit() + session.refresh(comment) + return comment diff --git a/backend/app/models.py b/backend/app/models.py index 2389b4a532..1af3becdf0 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -1,4 +1,7 @@ import uuid +from datetime import datetime +from enum import Enum +from typing import Optional from pydantic import EmailStr from sqlmodel import Field, Relationship, SQLModel @@ -111,3 +114,102 @@ class TokenPayload(SQLModel): class NewPassword(SQLModel): token: str new_password: str = Field(min_length=8, max_length=40) + + +# Ticket Enums +class TicketCategory(str, Enum): + SUPPORT = "Suporte" + MAINTENANCE = "Manutenção" + QUESTION = "Dúvida" + + +class TicketPriority(str, Enum): + LOW = "Baixa" + MEDIUM = "Média" + HIGH = "Alta" + + +class TicketStatus(str, Enum): + OPEN = "Aberto" + IN_PROGRESS = "Em andamento" + CLOSED = "Fechado" + + +# Ticket models +class TicketBase(SQLModel): + title: str = Field(min_length=1, max_length=255) + description: str | None = Field(default=None) + category: TicketCategory + priority: TicketPriority + status: TicketStatus = Field(default=TicketStatus.OPEN) + + +class TicketCreate(TicketBase): + pass + + +class TicketUpdate(SQLModel): + title: str | None = Field(default=None, min_length=1, max_length=255) + description: str | None = Field(default=None) + category: TicketCategory | None = None + priority: TicketPriority | None = None + status: TicketStatus | None = None + + +class Ticket(TicketBase, table=True): + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) + user_id: uuid.UUID = Field(foreign_key="user.id", nullable=False) + created_at: datetime = Field(default_factory=datetime.utcnow) + updated_at: datetime = Field(default_factory=datetime.utcnow, sa_column_kwargs={"onupdate": datetime.utcnow}) + + # Relationships + user: User = Relationship(back_populates="tickets") + comments: list["Comment"] = Relationship(back_populates="ticket", sa_relationship_kwargs={"cascade": "all, delete-orphan"}) + + +# Comment models +class CommentBase(SQLModel): + comment: str = Field(min_length=1) + + +class CommentCreate(CommentBase): + pass + + +class Comment(CommentBase, table=True): + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) + ticket_id: uuid.UUID = Field(foreign_key="ticket.id", nullable=False) + user_id: uuid.UUID = Field(foreign_key="user.id", nullable=False) + created_at: datetime = Field(default_factory=datetime.utcnow) + + # Relationships + ticket: Ticket = Relationship(back_populates="comments") + user: User = Relationship() + + +# API response models +class CommentPublic(CommentBase): + id: uuid.UUID + created_at: datetime + + +class TicketPublic(TicketBase): + id: uuid.UUID + user_id: uuid.UUID + created_at: datetime + updated_at: datetime + + +class TicketDetailPublic(TicketPublic): + comments: list[CommentPublic] = [] + + +class TicketsPublic(SQLModel): + data: list[TicketPublic] + count: int + page: int + + +# Update User model to include tickets relationship +User.model_rebuild() +setattr(User, "tickets", Relationship(back_populates="user", sa_relationship_kwargs={"cascade": "all, delete-orphan"})) From bac2fded78eeee0b192a6c3a418ceb2781f323cd Mon Sep 17 00:00:00 2001 From: Marcelo Mizuno Date: Mon, 7 Apr 2025 17:35:31 -0300 Subject: [PATCH 05/19] Added middleware --- backend/app/api/routes/utils.py | 15 + backend/app/core/middleware.py | 73 +++++ backend/app/main.py | 62 ++-- backend/app/models.py | 6 +- backend/app/tests/api/routes/test_tickets.py | 310 +++++++++++++++++++ backend/app/tests/utils/ticket.py | 78 +++++ 6 files changed, 520 insertions(+), 24 deletions(-) create mode 100644 backend/app/core/middleware.py create mode 100644 backend/app/tests/api/routes/test_tickets.py create mode 100644 backend/app/tests/utils/ticket.py diff --git a/backend/app/api/routes/utils.py b/backend/app/api/routes/utils.py index fc093419b3..3bb1aca345 100644 --- a/backend/app/api/routes/utils.py +++ b/backend/app/api/routes/utils.py @@ -29,3 +29,18 @@ def test_email(email_to: EmailStr) -> Message: @router.get("/health-check/") async def health_check() -> bool: return True + + +@router.get("/cors-debug", tags=["utils"], response_model=dict) +def cors_debug() -> dict: + """ + Endpoint for debugging CORS settings + """ + from app.core.config import settings + + return { + "cors_origins": settings.BACKEND_CORS_ORIGINS, + "all_cors_origins": settings.all_cors_origins, + "frontend_host": settings.FRONTEND_HOST, + "environment": settings.ENVIRONMENT, + } diff --git a/backend/app/core/middleware.py b/backend/app/core/middleware.py new file mode 100644 index 0000000000..3674f5ca78 --- /dev/null +++ b/backend/app/core/middleware.py @@ -0,0 +1,73 @@ +import logging +from typing import Callable + +from fastapi import FastAPI, Request, Response +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.middleware.cors import CORSMiddleware +from starlette.types import ASGIApp + +from app.core.config import settings + +logger = logging.getLogger(__name__) + + +class CustomCORSMiddleware(BaseHTTPMiddleware): + def __init__( + self, + app: ASGIApp, + ) -> None: + super().__init__(app) + self.allowed_origins = settings.all_cors_origins + logger.info(f"Configured CORS origins: {self.allowed_origins}") + + async def dispatch(self, request: Request, call_next: Callable) -> Response: + origin = request.headers.get("origin", "") + logger.info(f"Request from origin: {origin}") + + # Handle preflight OPTIONS requests explicitly + if request.method == "OPTIONS": + response = Response(status_code=200) + self._set_cors_headers(response, origin) + return response + + # For all other requests, continue with normal processing + response = await call_next(request) + + # Add CORS headers to response + self._set_cors_headers(response, origin) + + return response + + def _set_cors_headers(self, response: Response, origin: str) -> None: + if not origin: + return + + # Force add origin if in production environment + if settings.ENVIRONMENT != "local" and origin: + response.headers["Access-Control-Allow-Origin"] = origin + response.headers["Access-Control-Allow-Credentials"] = "true" + response.headers["Access-Control-Allow-Methods"] = "GET, POST, PUT, DELETE, OPTIONS" + response.headers["Access-Control-Allow-Headers"] = "Authorization, Content-Type, Accept" + response.headers["Access-Control-Max-Age"] = "3600" + logger.info(f"Added CORS headers for origin: {origin}") + elif origin in self.allowed_origins: + response.headers["Access-Control-Allow-Origin"] = origin + response.headers["Access-Control-Allow-Credentials"] = "true" + response.headers["Access-Control-Allow-Methods"] = "GET, POST, PUT, DELETE, OPTIONS" + response.headers["Access-Control-Allow-Headers"] = "Authorization, Content-Type, Accept" + response.headers["Access-Control-Max-Age"] = "3600" + logger.info(f"Added CORS headers for origin: {origin}") + else: + logger.warning(f"Origin not allowed: {origin}") + + +def setup_cors(app: FastAPI) -> None: + """Configure CORS for the application.""" + + # Remove any existing CORS middleware + app.middleware_stack = app.build_middleware_stack() + + # Add our custom CORS middleware + app.add_middleware(CustomCORSMiddleware) + + logger.info("Custom CORS middleware configured") diff --git a/backend/app/main.py b/backend/app/main.py index 9a95801e74..bcddbf7620 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,33 +1,57 @@ -import sentry_sdk +import logging +from contextlib import asynccontextmanager + from fastapi import FastAPI -from fastapi.routing import APIRoute -from starlette.middleware.cors import CORSMiddleware +from fastapi.staticfiles import StaticFiles from app.api.main import api_router from app.core.config import settings +from app.core.middleware import setup_cors + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(levelname)s: %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", +) +logger = logging.getLogger(__name__) -def custom_generate_unique_id(route: APIRoute) -> str: - return f"{route.tags[0]}-{route.name}" +@asynccontextmanager +async def lifespan(app: FastAPI): + """ + Function that runs before the application starts, + and again when the application is shutting down. + For now, just a placeholder, but we can use this to setup + database connections, etc. + """ + yield -if settings.SENTRY_DSN and settings.ENVIRONMENT != "local": - sentry_sdk.init(dsn=str(settings.SENTRY_DSN), enable_tracing=True) app = FastAPI( - title=settings.PROJECT_NAME, - openapi_url=f"{settings.API_V1_STR}/openapi.json", - generate_unique_id_function=custom_generate_unique_id, + title=settings.PROJECT_NAME, openapi_url=f"{settings.API_V1_STR}/openapi.json", lifespan=lifespan ) -# Set all CORS enabled origins -if settings.all_cors_origins: - app.add_middleware( - CORSMiddleware, - allow_origins=settings.all_cors_origins, - allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], - ) +# Use our custom CORS middleware instead of the default FastAPI one +setup_cors(app) app.include_router(api_router, prefix=settings.API_V1_STR) + +# Mount the static files directory to serve static files +# app.mount("/static", StaticFiles(directory="static"), name="static") + + +@app.get("/") +def root(): + """ + Root endpoint for health checks + """ + return {"message": "Hello! This is the API root. Go to /docs for API documentation."} + + +@app.get("/health") +def health_check(): + """ + Health check endpoint + """ + return {"status": "ok"} diff --git a/backend/app/models.py b/backend/app/models.py index 1af3becdf0..04f8e6420d 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -47,6 +47,7 @@ class User(UserBase, table=True): id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) hashed_password: str items: list["Item"] = Relationship(back_populates="owner", cascade_delete=True) + tickets: list["Ticket"] = Relationship(back_populates="user", sa_relationship_kwargs={"cascade": "all, delete-orphan"}) # Properties to return via API, id is always required @@ -208,8 +209,3 @@ class TicketsPublic(SQLModel): data: list[TicketPublic] count: int page: int - - -# Update User model to include tickets relationship -User.model_rebuild() -setattr(User, "tickets", Relationship(back_populates="user", sa_relationship_kwargs={"cascade": "all, delete-orphan"})) diff --git a/backend/app/tests/api/routes/test_tickets.py b/backend/app/tests/api/routes/test_tickets.py new file mode 100644 index 0000000000..ed6fe48f13 --- /dev/null +++ b/backend/app/tests/api/routes/test_tickets.py @@ -0,0 +1,310 @@ +import uuid + +from fastapi.testclient import TestClient +from sqlmodel import Session + +from app.core.config import settings +from app.models import TicketCategory, TicketPriority, TicketStatus +from app.tests.utils.ticket import create_random_ticket, create_random_comment +from app.tests.utils.user import create_random_user + + +def test_create_ticket( + client: TestClient, superuser_token_headers: dict[str, str] +) -> None: + """ + Teste para criar um ticket + """ + data = { + "title": "Problemas com login", + "description": "Não consigo fazer login no sistema", + "category": TicketCategory.SUPPORT, + "priority": TicketPriority.MEDIUM, + } + response = client.post( + f"{settings.API_V1_STR}/tickets/", + headers=superuser_token_headers, + json=data, + ) + assert response.status_code == 200 + content = response.json() + assert content["title"] == data["title"] + assert content["description"] == data["description"] + assert content["category"] == data["category"] + assert content["priority"] == data["priority"] + assert content["status"] == TicketStatus.OPEN # Status padrão + assert "id" in content + assert "user_id" in content + assert "created_at" in content + assert "updated_at" in content + + +def test_read_ticket( + client: TestClient, superuser_token_headers: dict[str, str], db: Session +) -> None: + """ + Teste para ler um ticket específico + """ + ticket = create_random_ticket(db) + response = client.get( + f"{settings.API_V1_STR}/tickets/{ticket.id}", + headers=superuser_token_headers, + ) + assert response.status_code == 200 + content = response.json() + assert content["title"] == ticket.title + assert content["description"] == ticket.description + assert content["category"] == ticket.category + assert content["priority"] == ticket.priority + assert content["status"] == ticket.status + assert content["id"] == str(ticket.id) + assert content["user_id"] == str(ticket.user_id) + assert "created_at" in content + assert "updated_at" in content + assert "comments" in content + + +def test_read_ticket_with_comments( + client: TestClient, superuser_token_headers: dict[str, str], db: Session +) -> None: + """ + Teste para ler um ticket com comentários + """ + ticket = create_random_ticket(db) + comment = create_random_comment(db, ticket_id=ticket.id) + + response = client.get( + f"{settings.API_V1_STR}/tickets/{ticket.id}", + headers=superuser_token_headers, + ) + assert response.status_code == 200 + content = response.json() + assert len(content["comments"]) == 1 + assert content["comments"][0]["id"] == str(comment.id) + assert content["comments"][0]["comment"] == comment.comment + + +def test_read_ticket_not_found( + client: TestClient, superuser_token_headers: dict[str, str] +) -> None: + """ + Teste para ler um ticket que não existe + """ + response = client.get( + f"{settings.API_V1_STR}/tickets/{uuid.uuid4()}", + headers=superuser_token_headers, + ) + assert response.status_code == 404 + content = response.json() + assert content["detail"] == "Ticket não encontrado" + + +def test_read_ticket_not_enough_permissions( + client: TestClient, normal_user_token_headers: dict[str, str], db: Session +) -> None: + """ + Teste para ler um ticket sem permissões suficientes + """ + # Criar um ticket com um usuário diferente + other_user = create_random_user(db) + ticket = create_random_ticket(db, owner_id=other_user.id) + + response = client.get( + f"{settings.API_V1_STR}/tickets/{ticket.id}", + headers=normal_user_token_headers, + ) + assert response.status_code == 400 + content = response.json() + assert content["detail"] == "Permissões insuficientes" + + +def test_read_tickets( + client: TestClient, superuser_token_headers: dict[str, str], db: Session +) -> None: + """ + Teste para ler todos os tickets + """ + create_random_ticket(db) + create_random_ticket(db) + response = client.get( + f"{settings.API_V1_STR}/tickets/", + headers=superuser_token_headers, + ) + assert response.status_code == 200 + content = response.json() + assert len(content["data"]) >= 2 + assert "count" in content + assert "page" in content + + +def test_filter_tickets_by_category( + client: TestClient, superuser_token_headers: dict[str, str], db: Session +) -> None: + """ + Teste para filtrar tickets por categoria + """ + ticket = create_random_ticket(db) + category = ticket.category + + response = client.get( + f"{settings.API_V1_STR}/tickets/?category={category}", + headers=superuser_token_headers, + ) + assert response.status_code == 200 + content = response.json() + assert all(t["category"] == category for t in content["data"]) + + +def test_update_ticket( + client: TestClient, superuser_token_headers: dict[str, str], db: Session +) -> None: + """ + Teste para atualizar um ticket + """ + ticket = create_random_ticket(db) + data = { + "title": "Título atualizado", + "description": "Descrição atualizada", + "category": TicketCategory.MAINTENANCE, + "priority": TicketPriority.HIGH, + "status": TicketStatus.IN_PROGRESS + } + response = client.put( + f"{settings.API_V1_STR}/tickets/{ticket.id}", + headers=superuser_token_headers, + json=data, + ) + assert response.status_code == 200 + content = response.json() + assert content["title"] == data["title"] + assert content["description"] == data["description"] + assert content["category"] == data["category"] + assert content["priority"] == data["priority"] + assert content["status"] == data["status"] + assert content["id"] == str(ticket.id) + assert content["user_id"] == str(ticket.user_id) + + +def test_update_ticket_not_found( + client: TestClient, superuser_token_headers: dict[str, str] +) -> None: + """ + Teste para atualizar um ticket que não existe + """ + data = {"title": "Título atualizado", "description": "Descrição atualizada"} + response = client.put( + f"{settings.API_V1_STR}/tickets/{uuid.uuid4()}", + headers=superuser_token_headers, + json=data, + ) + assert response.status_code == 404 + content = response.json() + assert content["detail"] == "Ticket não encontrado" + + +def test_update_ticket_not_enough_permissions( + client: TestClient, normal_user_token_headers: dict[str, str], db: Session +) -> None: + """ + Teste para atualizar um ticket sem permissões suficientes + """ + other_user = create_random_user(db) + ticket = create_random_ticket(db, owner_id=other_user.id) + data = {"title": "Título atualizado", "description": "Descrição atualizada"} + + response = client.put( + f"{settings.API_V1_STR}/tickets/{ticket.id}", + headers=normal_user_token_headers, + json=data, + ) + assert response.status_code == 400 + content = response.json() + assert content["detail"] == "Permissões insuficientes" + + +def test_delete_ticket( + client: TestClient, superuser_token_headers: dict[str, str], db: Session +) -> None: + """ + Teste para excluir um ticket + """ + ticket = create_random_ticket(db) + response = client.delete( + f"{settings.API_V1_STR}/tickets/{ticket.id}", + headers=superuser_token_headers, + ) + assert response.status_code == 200 + content = response.json() + assert content["message"] == "Ticket removido com sucesso" + + +def test_delete_ticket_not_found( + client: TestClient, superuser_token_headers: dict[str, str] +) -> None: + """ + Teste para excluir um ticket que não existe + """ + response = client.delete( + f"{settings.API_V1_STR}/tickets/{uuid.uuid4()}", + headers=superuser_token_headers, + ) + assert response.status_code == 404 + content = response.json() + assert content["detail"] == "Ticket não encontrado" + + +def test_delete_ticket_not_enough_permissions( + client: TestClient, normal_user_token_headers: dict[str, str], db: Session +) -> None: + """ + Teste para excluir um ticket sem permissões suficientes + """ + other_user = create_random_user(db) + ticket = create_random_ticket(db, owner_id=other_user.id) + + response = client.delete( + f"{settings.API_V1_STR}/tickets/{ticket.id}", + headers=normal_user_token_headers, + ) + assert response.status_code == 400 + content = response.json() + assert content["detail"] == "Permissões insuficientes" + + +def test_create_comment( + client: TestClient, superuser_token_headers: dict[str, str], db: Session +) -> None: + """ + Teste para criar um comentário em um ticket + """ + ticket = create_random_ticket(db) + data = {"comment": "Este é um comentário de teste"} + + response = client.post( + f"{settings.API_V1_STR}/tickets/{ticket.id}/comments", + headers=superuser_token_headers, + json=data, + ) + assert response.status_code == 200 + content = response.json() + assert content["comment"] == data["comment"] + assert "id" in content + assert "created_at" in content + + +def test_create_comment_ticket_not_found( + client: TestClient, superuser_token_headers: dict[str, str] +) -> None: + """ + Teste para criar um comentário em um ticket que não existe + """ + data = {"comment": "Este é um comentário de teste"} + + response = client.post( + f"{settings.API_V1_STR}/tickets/{uuid.uuid4()}/comments", + headers=superuser_token_headers, + json=data, + ) + assert response.status_code == 404 + content = response.json() + assert content["detail"] == "Ticket não encontrado" diff --git a/backend/app/tests/utils/ticket.py b/backend/app/tests/utils/ticket.py new file mode 100644 index 0000000000..01434946b1 --- /dev/null +++ b/backend/app/tests/utils/ticket.py @@ -0,0 +1,78 @@ +import random +import uuid +from datetime import datetime + +from sqlmodel import Session + +from app.models import ( + Ticket, + Comment, + User, + TicketCategory, + TicketPriority, + TicketStatus +) +from app.tests.utils.user import create_random_user +from app.tests.utils.utils import random_lower_string + + +def create_random_ticket(db: Session, *, owner_id: uuid.UUID | None = None) -> Ticket: + """ + Create a random ticket for testing + """ + if owner_id is None: + user = create_random_user(db) + owner_id = user.id + + title = random_lower_string() + description = random_lower_string() + + # Randomly select category, priority, and status + category = random.choice(list(TicketCategory)) + priority = random.choice(list(TicketPriority)) + status = random.choice(list(TicketStatus)) + + ticket = Ticket( + title=title, + description=description, + category=category, + priority=priority, + status=status, + user_id=owner_id, + created_at=datetime.utcnow(), + updated_at=datetime.utcnow(), + ) + + db.add(ticket) + db.commit() + db.refresh(ticket) + return ticket + + +def create_random_comment( + db: Session, *, ticket_id: uuid.UUID | None = None, user_id: uuid.UUID | None = None +) -> Comment: + """ + Create a random comment for testing + """ + if ticket_id is None: + ticket = create_random_ticket(db) + ticket_id = ticket.id + + if user_id is None: + user = create_random_user(db) + user_id = user.id + + comment_text = random_lower_string() + + comment = Comment( + comment=comment_text, + ticket_id=ticket_id, + user_id=user_id, + created_at=datetime.utcnow(), + ) + + db.add(comment) + db.commit() + db.refresh(comment) + return comment From 80eb9729bcd60806d3aeadd6c9a94d9fdc6e6c6f Mon Sep 17 00:00:00 2001 From: Marcelo Mizuno Date: Mon, 7 Apr 2025 17:39:59 -0300 Subject: [PATCH 06/19] Middleware fix --- backend/app/api/routes/utils.py | 10 ++--- backend/app/core/middleware.py | 79 ++++++++------------------------- backend/app/main.py | 4 ++ 3 files changed, 27 insertions(+), 66 deletions(-) diff --git a/backend/app/api/routes/utils.py b/backend/app/api/routes/utils.py index 3bb1aca345..cb93f5eb7d 100644 --- a/backend/app/api/routes/utils.py +++ b/backend/app/api/routes/utils.py @@ -31,16 +31,16 @@ async def health_check() -> bool: return True -@router.get("/cors-debug", tags=["utils"], response_model=dict) -def cors_debug() -> dict: +@router.get("/cors-debug", tags=["utils"]) +def cors_debug(): """ - Endpoint for debugging CORS settings + Returns CORS configuration information for debugging. """ from app.core.config import settings return { - "cors_origins": settings.BACKEND_CORS_ORIGINS, + "cors_origins": [str(origin) for origin in settings.BACKEND_CORS_ORIGINS], "all_cors_origins": settings.all_cors_origins, "frontend_host": settings.FRONTEND_HOST, - "environment": settings.ENVIRONMENT, + "environment": settings.ENVIRONMENT } diff --git a/backend/app/core/middleware.py b/backend/app/core/middleware.py index 3674f5ca78..12b5d1f3af 100644 --- a/backend/app/core/middleware.py +++ b/backend/app/core/middleware.py @@ -1,73 +1,30 @@ import logging -from typing import Callable +from typing import Callable, List -from fastapi import FastAPI, Request, Response -from starlette.middleware.base import BaseHTTPMiddleware +from fastapi import FastAPI from starlette.middleware.cors import CORSMiddleware -from starlette.types import ASGIApp from app.core.config import settings logger = logging.getLogger(__name__) - -class CustomCORSMiddleware(BaseHTTPMiddleware): - def __init__( - self, - app: ASGIApp, - ) -> None: - super().__init__(app) - self.allowed_origins = settings.all_cors_origins - logger.info(f"Configured CORS origins: {self.allowed_origins}") - - async def dispatch(self, request: Request, call_next: Callable) -> Response: - origin = request.headers.get("origin", "") - logger.info(f"Request from origin: {origin}") - - # Handle preflight OPTIONS requests explicitly - if request.method == "OPTIONS": - response = Response(status_code=200) - self._set_cors_headers(response, origin) - return response - - # For all other requests, continue with normal processing - response = await call_next(request) - - # Add CORS headers to response - self._set_cors_headers(response, origin) - - return response - - def _set_cors_headers(self, response: Response, origin: str) -> None: - if not origin: - return - - # Force add origin if in production environment - if settings.ENVIRONMENT != "local" and origin: - response.headers["Access-Control-Allow-Origin"] = origin - response.headers["Access-Control-Allow-Credentials"] = "true" - response.headers["Access-Control-Allow-Methods"] = "GET, POST, PUT, DELETE, OPTIONS" - response.headers["Access-Control-Allow-Headers"] = "Authorization, Content-Type, Accept" - response.headers["Access-Control-Max-Age"] = "3600" - logger.info(f"Added CORS headers for origin: {origin}") - elif origin in self.allowed_origins: - response.headers["Access-Control-Allow-Origin"] = origin - response.headers["Access-Control-Allow-Credentials"] = "true" - response.headers["Access-Control-Allow-Methods"] = "GET, POST, PUT, DELETE, OPTIONS" - response.headers["Access-Control-Allow-Headers"] = "Authorization, Content-Type, Accept" - response.headers["Access-Control-Max-Age"] = "3600" - logger.info(f"Added CORS headers for origin: {origin}") - else: - logger.warning(f"Origin not allowed: {origin}") - - def setup_cors(app: FastAPI) -> None: - """Configure CORS for the application.""" + """ + Configure CORS for the application - must be called before app startup. + """ + origins = settings.all_cors_origins - # Remove any existing CORS middleware - app.middleware_stack = app.build_middleware_stack() + logger.info(f"Setting up CORS middleware with origins: {origins}") - # Add our custom CORS middleware - app.add_middleware(CustomCORSMiddleware) + # Add CORS middleware + app.add_middleware( + CORSMiddleware, + allow_origins=origins or ["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + expose_headers=["Content-Disposition"], + max_age=3600, + ) - logger.info("Custom CORS middleware configured") + logger.info("CORS middleware configured") diff --git a/backend/app/main.py b/backend/app/main.py index bcddbf7620..fc786a0de0 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -32,6 +32,10 @@ async def lifespan(app: FastAPI): title=settings.PROJECT_NAME, openapi_url=f"{settings.API_V1_STR}/openapi.json", lifespan=lifespan ) +# Debug logging for CORS origins +origins = settings.all_cors_origins +logger.info(f"CORS origins: {origins}") + # Use our custom CORS middleware instead of the default FastAPI one setup_cors(app) From 0bd34d843ee932e76ea10b39f196d3e6347ab4f4 Mon Sep 17 00:00:00 2001 From: Marcelo Mizuno Date: Mon, 7 Apr 2025 17:50:44 -0300 Subject: [PATCH 07/19] Revert "Middleware fix" This reverts commit 80eb9729bcd60806d3aeadd6c9a94d9fdc6e6c6f. --- backend/app/api/routes/utils.py | 10 ++--- backend/app/core/middleware.py | 79 +++++++++++++++++++++++++-------- backend/app/main.py | 4 -- 3 files changed, 66 insertions(+), 27 deletions(-) diff --git a/backend/app/api/routes/utils.py b/backend/app/api/routes/utils.py index cb93f5eb7d..3bb1aca345 100644 --- a/backend/app/api/routes/utils.py +++ b/backend/app/api/routes/utils.py @@ -31,16 +31,16 @@ async def health_check() -> bool: return True -@router.get("/cors-debug", tags=["utils"]) -def cors_debug(): +@router.get("/cors-debug", tags=["utils"], response_model=dict) +def cors_debug() -> dict: """ - Returns CORS configuration information for debugging. + Endpoint for debugging CORS settings """ from app.core.config import settings return { - "cors_origins": [str(origin) for origin in settings.BACKEND_CORS_ORIGINS], + "cors_origins": settings.BACKEND_CORS_ORIGINS, "all_cors_origins": settings.all_cors_origins, "frontend_host": settings.FRONTEND_HOST, - "environment": settings.ENVIRONMENT + "environment": settings.ENVIRONMENT, } diff --git a/backend/app/core/middleware.py b/backend/app/core/middleware.py index 12b5d1f3af..3674f5ca78 100644 --- a/backend/app/core/middleware.py +++ b/backend/app/core/middleware.py @@ -1,30 +1,73 @@ import logging -from typing import Callable, List +from typing import Callable -from fastapi import FastAPI +from fastapi import FastAPI, Request, Response +from starlette.middleware.base import BaseHTTPMiddleware from starlette.middleware.cors import CORSMiddleware +from starlette.types import ASGIApp from app.core.config import settings logger = logging.getLogger(__name__) + +class CustomCORSMiddleware(BaseHTTPMiddleware): + def __init__( + self, + app: ASGIApp, + ) -> None: + super().__init__(app) + self.allowed_origins = settings.all_cors_origins + logger.info(f"Configured CORS origins: {self.allowed_origins}") + + async def dispatch(self, request: Request, call_next: Callable) -> Response: + origin = request.headers.get("origin", "") + logger.info(f"Request from origin: {origin}") + + # Handle preflight OPTIONS requests explicitly + if request.method == "OPTIONS": + response = Response(status_code=200) + self._set_cors_headers(response, origin) + return response + + # For all other requests, continue with normal processing + response = await call_next(request) + + # Add CORS headers to response + self._set_cors_headers(response, origin) + + return response + + def _set_cors_headers(self, response: Response, origin: str) -> None: + if not origin: + return + + # Force add origin if in production environment + if settings.ENVIRONMENT != "local" and origin: + response.headers["Access-Control-Allow-Origin"] = origin + response.headers["Access-Control-Allow-Credentials"] = "true" + response.headers["Access-Control-Allow-Methods"] = "GET, POST, PUT, DELETE, OPTIONS" + response.headers["Access-Control-Allow-Headers"] = "Authorization, Content-Type, Accept" + response.headers["Access-Control-Max-Age"] = "3600" + logger.info(f"Added CORS headers for origin: {origin}") + elif origin in self.allowed_origins: + response.headers["Access-Control-Allow-Origin"] = origin + response.headers["Access-Control-Allow-Credentials"] = "true" + response.headers["Access-Control-Allow-Methods"] = "GET, POST, PUT, DELETE, OPTIONS" + response.headers["Access-Control-Allow-Headers"] = "Authorization, Content-Type, Accept" + response.headers["Access-Control-Max-Age"] = "3600" + logger.info(f"Added CORS headers for origin: {origin}") + else: + logger.warning(f"Origin not allowed: {origin}") + + def setup_cors(app: FastAPI) -> None: - """ - Configure CORS for the application - must be called before app startup. - """ - origins = settings.all_cors_origins + """Configure CORS for the application.""" - logger.info(f"Setting up CORS middleware with origins: {origins}") + # Remove any existing CORS middleware + app.middleware_stack = app.build_middleware_stack() - # Add CORS middleware - app.add_middleware( - CORSMiddleware, - allow_origins=origins or ["*"], - allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], - expose_headers=["Content-Disposition"], - max_age=3600, - ) + # Add our custom CORS middleware + app.add_middleware(CustomCORSMiddleware) - logger.info("CORS middleware configured") + logger.info("Custom CORS middleware configured") diff --git a/backend/app/main.py b/backend/app/main.py index fc786a0de0..bcddbf7620 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -32,10 +32,6 @@ async def lifespan(app: FastAPI): title=settings.PROJECT_NAME, openapi_url=f"{settings.API_V1_STR}/openapi.json", lifespan=lifespan ) -# Debug logging for CORS origins -origins = settings.all_cors_origins -logger.info(f"CORS origins: {origins}") - # Use our custom CORS middleware instead of the default FastAPI one setup_cors(app) From 3f0ccb234462820e3783dbfe2b900a7fff0eb6a5 Mon Sep 17 00:00:00 2001 From: Marcelo Mizuno Date: Mon, 7 Apr 2025 17:50:56 -0300 Subject: [PATCH 08/19] Revert "Added middleware" This reverts commit bac2fded78eeee0b192a6c3a418ceb2781f323cd. --- backend/app/api/routes/utils.py | 15 - backend/app/core/middleware.py | 73 ----- backend/app/main.py | 62 ++-- backend/app/models.py | 6 +- backend/app/tests/api/routes/test_tickets.py | 310 ------------------- backend/app/tests/utils/ticket.py | 78 ----- 6 files changed, 24 insertions(+), 520 deletions(-) delete mode 100644 backend/app/core/middleware.py delete mode 100644 backend/app/tests/api/routes/test_tickets.py delete mode 100644 backend/app/tests/utils/ticket.py diff --git a/backend/app/api/routes/utils.py b/backend/app/api/routes/utils.py index 3bb1aca345..fc093419b3 100644 --- a/backend/app/api/routes/utils.py +++ b/backend/app/api/routes/utils.py @@ -29,18 +29,3 @@ def test_email(email_to: EmailStr) -> Message: @router.get("/health-check/") async def health_check() -> bool: return True - - -@router.get("/cors-debug", tags=["utils"], response_model=dict) -def cors_debug() -> dict: - """ - Endpoint for debugging CORS settings - """ - from app.core.config import settings - - return { - "cors_origins": settings.BACKEND_CORS_ORIGINS, - "all_cors_origins": settings.all_cors_origins, - "frontend_host": settings.FRONTEND_HOST, - "environment": settings.ENVIRONMENT, - } diff --git a/backend/app/core/middleware.py b/backend/app/core/middleware.py deleted file mode 100644 index 3674f5ca78..0000000000 --- a/backend/app/core/middleware.py +++ /dev/null @@ -1,73 +0,0 @@ -import logging -from typing import Callable - -from fastapi import FastAPI, Request, Response -from starlette.middleware.base import BaseHTTPMiddleware -from starlette.middleware.cors import CORSMiddleware -from starlette.types import ASGIApp - -from app.core.config import settings - -logger = logging.getLogger(__name__) - - -class CustomCORSMiddleware(BaseHTTPMiddleware): - def __init__( - self, - app: ASGIApp, - ) -> None: - super().__init__(app) - self.allowed_origins = settings.all_cors_origins - logger.info(f"Configured CORS origins: {self.allowed_origins}") - - async def dispatch(self, request: Request, call_next: Callable) -> Response: - origin = request.headers.get("origin", "") - logger.info(f"Request from origin: {origin}") - - # Handle preflight OPTIONS requests explicitly - if request.method == "OPTIONS": - response = Response(status_code=200) - self._set_cors_headers(response, origin) - return response - - # For all other requests, continue with normal processing - response = await call_next(request) - - # Add CORS headers to response - self._set_cors_headers(response, origin) - - return response - - def _set_cors_headers(self, response: Response, origin: str) -> None: - if not origin: - return - - # Force add origin if in production environment - if settings.ENVIRONMENT != "local" and origin: - response.headers["Access-Control-Allow-Origin"] = origin - response.headers["Access-Control-Allow-Credentials"] = "true" - response.headers["Access-Control-Allow-Methods"] = "GET, POST, PUT, DELETE, OPTIONS" - response.headers["Access-Control-Allow-Headers"] = "Authorization, Content-Type, Accept" - response.headers["Access-Control-Max-Age"] = "3600" - logger.info(f"Added CORS headers for origin: {origin}") - elif origin in self.allowed_origins: - response.headers["Access-Control-Allow-Origin"] = origin - response.headers["Access-Control-Allow-Credentials"] = "true" - response.headers["Access-Control-Allow-Methods"] = "GET, POST, PUT, DELETE, OPTIONS" - response.headers["Access-Control-Allow-Headers"] = "Authorization, Content-Type, Accept" - response.headers["Access-Control-Max-Age"] = "3600" - logger.info(f"Added CORS headers for origin: {origin}") - else: - logger.warning(f"Origin not allowed: {origin}") - - -def setup_cors(app: FastAPI) -> None: - """Configure CORS for the application.""" - - # Remove any existing CORS middleware - app.middleware_stack = app.build_middleware_stack() - - # Add our custom CORS middleware - app.add_middleware(CustomCORSMiddleware) - - logger.info("Custom CORS middleware configured") diff --git a/backend/app/main.py b/backend/app/main.py index bcddbf7620..9a95801e74 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,57 +1,33 @@ -import logging -from contextlib import asynccontextmanager - +import sentry_sdk from fastapi import FastAPI -from fastapi.staticfiles import StaticFiles +from fastapi.routing import APIRoute +from starlette.middleware.cors import CORSMiddleware from app.api.main import api_router from app.core.config import settings -from app.core.middleware import setup_cors - -logging.basicConfig( - level=logging.INFO, - format="%(asctime)s %(levelname)s: %(message)s", - datefmt="%Y-%m-%d %H:%M:%S", -) -logger = logging.getLogger(__name__) -@asynccontextmanager -async def lifespan(app: FastAPI): - """ - Function that runs before the application starts, - and again when the application is shutting down. +def custom_generate_unique_id(route: APIRoute) -> str: + return f"{route.tags[0]}-{route.name}" - For now, just a placeholder, but we can use this to setup - database connections, etc. - """ - yield +if settings.SENTRY_DSN and settings.ENVIRONMENT != "local": + sentry_sdk.init(dsn=str(settings.SENTRY_DSN), enable_tracing=True) app = FastAPI( - title=settings.PROJECT_NAME, openapi_url=f"{settings.API_V1_STR}/openapi.json", lifespan=lifespan + title=settings.PROJECT_NAME, + openapi_url=f"{settings.API_V1_STR}/openapi.json", + generate_unique_id_function=custom_generate_unique_id, ) -# Use our custom CORS middleware instead of the default FastAPI one -setup_cors(app) +# Set all CORS enabled origins +if settings.all_cors_origins: + app.add_middleware( + CORSMiddleware, + allow_origins=settings.all_cors_origins, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) app.include_router(api_router, prefix=settings.API_V1_STR) - -# Mount the static files directory to serve static files -# app.mount("/static", StaticFiles(directory="static"), name="static") - - -@app.get("/") -def root(): - """ - Root endpoint for health checks - """ - return {"message": "Hello! This is the API root. Go to /docs for API documentation."} - - -@app.get("/health") -def health_check(): - """ - Health check endpoint - """ - return {"status": "ok"} diff --git a/backend/app/models.py b/backend/app/models.py index 04f8e6420d..1af3becdf0 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -47,7 +47,6 @@ class User(UserBase, table=True): id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) hashed_password: str items: list["Item"] = Relationship(back_populates="owner", cascade_delete=True) - tickets: list["Ticket"] = Relationship(back_populates="user", sa_relationship_kwargs={"cascade": "all, delete-orphan"}) # Properties to return via API, id is always required @@ -209,3 +208,8 @@ class TicketsPublic(SQLModel): data: list[TicketPublic] count: int page: int + + +# Update User model to include tickets relationship +User.model_rebuild() +setattr(User, "tickets", Relationship(back_populates="user", sa_relationship_kwargs={"cascade": "all, delete-orphan"})) diff --git a/backend/app/tests/api/routes/test_tickets.py b/backend/app/tests/api/routes/test_tickets.py deleted file mode 100644 index ed6fe48f13..0000000000 --- a/backend/app/tests/api/routes/test_tickets.py +++ /dev/null @@ -1,310 +0,0 @@ -import uuid - -from fastapi.testclient import TestClient -from sqlmodel import Session - -from app.core.config import settings -from app.models import TicketCategory, TicketPriority, TicketStatus -from app.tests.utils.ticket import create_random_ticket, create_random_comment -from app.tests.utils.user import create_random_user - - -def test_create_ticket( - client: TestClient, superuser_token_headers: dict[str, str] -) -> None: - """ - Teste para criar um ticket - """ - data = { - "title": "Problemas com login", - "description": "Não consigo fazer login no sistema", - "category": TicketCategory.SUPPORT, - "priority": TicketPriority.MEDIUM, - } - response = client.post( - f"{settings.API_V1_STR}/tickets/", - headers=superuser_token_headers, - json=data, - ) - assert response.status_code == 200 - content = response.json() - assert content["title"] == data["title"] - assert content["description"] == data["description"] - assert content["category"] == data["category"] - assert content["priority"] == data["priority"] - assert content["status"] == TicketStatus.OPEN # Status padrão - assert "id" in content - assert "user_id" in content - assert "created_at" in content - assert "updated_at" in content - - -def test_read_ticket( - client: TestClient, superuser_token_headers: dict[str, str], db: Session -) -> None: - """ - Teste para ler um ticket específico - """ - ticket = create_random_ticket(db) - response = client.get( - f"{settings.API_V1_STR}/tickets/{ticket.id}", - headers=superuser_token_headers, - ) - assert response.status_code == 200 - content = response.json() - assert content["title"] == ticket.title - assert content["description"] == ticket.description - assert content["category"] == ticket.category - assert content["priority"] == ticket.priority - assert content["status"] == ticket.status - assert content["id"] == str(ticket.id) - assert content["user_id"] == str(ticket.user_id) - assert "created_at" in content - assert "updated_at" in content - assert "comments" in content - - -def test_read_ticket_with_comments( - client: TestClient, superuser_token_headers: dict[str, str], db: Session -) -> None: - """ - Teste para ler um ticket com comentários - """ - ticket = create_random_ticket(db) - comment = create_random_comment(db, ticket_id=ticket.id) - - response = client.get( - f"{settings.API_V1_STR}/tickets/{ticket.id}", - headers=superuser_token_headers, - ) - assert response.status_code == 200 - content = response.json() - assert len(content["comments"]) == 1 - assert content["comments"][0]["id"] == str(comment.id) - assert content["comments"][0]["comment"] == comment.comment - - -def test_read_ticket_not_found( - client: TestClient, superuser_token_headers: dict[str, str] -) -> None: - """ - Teste para ler um ticket que não existe - """ - response = client.get( - f"{settings.API_V1_STR}/tickets/{uuid.uuid4()}", - headers=superuser_token_headers, - ) - assert response.status_code == 404 - content = response.json() - assert content["detail"] == "Ticket não encontrado" - - -def test_read_ticket_not_enough_permissions( - client: TestClient, normal_user_token_headers: dict[str, str], db: Session -) -> None: - """ - Teste para ler um ticket sem permissões suficientes - """ - # Criar um ticket com um usuário diferente - other_user = create_random_user(db) - ticket = create_random_ticket(db, owner_id=other_user.id) - - response = client.get( - f"{settings.API_V1_STR}/tickets/{ticket.id}", - headers=normal_user_token_headers, - ) - assert response.status_code == 400 - content = response.json() - assert content["detail"] == "Permissões insuficientes" - - -def test_read_tickets( - client: TestClient, superuser_token_headers: dict[str, str], db: Session -) -> None: - """ - Teste para ler todos os tickets - """ - create_random_ticket(db) - create_random_ticket(db) - response = client.get( - f"{settings.API_V1_STR}/tickets/", - headers=superuser_token_headers, - ) - assert response.status_code == 200 - content = response.json() - assert len(content["data"]) >= 2 - assert "count" in content - assert "page" in content - - -def test_filter_tickets_by_category( - client: TestClient, superuser_token_headers: dict[str, str], db: Session -) -> None: - """ - Teste para filtrar tickets por categoria - """ - ticket = create_random_ticket(db) - category = ticket.category - - response = client.get( - f"{settings.API_V1_STR}/tickets/?category={category}", - headers=superuser_token_headers, - ) - assert response.status_code == 200 - content = response.json() - assert all(t["category"] == category for t in content["data"]) - - -def test_update_ticket( - client: TestClient, superuser_token_headers: dict[str, str], db: Session -) -> None: - """ - Teste para atualizar um ticket - """ - ticket = create_random_ticket(db) - data = { - "title": "Título atualizado", - "description": "Descrição atualizada", - "category": TicketCategory.MAINTENANCE, - "priority": TicketPriority.HIGH, - "status": TicketStatus.IN_PROGRESS - } - response = client.put( - f"{settings.API_V1_STR}/tickets/{ticket.id}", - headers=superuser_token_headers, - json=data, - ) - assert response.status_code == 200 - content = response.json() - assert content["title"] == data["title"] - assert content["description"] == data["description"] - assert content["category"] == data["category"] - assert content["priority"] == data["priority"] - assert content["status"] == data["status"] - assert content["id"] == str(ticket.id) - assert content["user_id"] == str(ticket.user_id) - - -def test_update_ticket_not_found( - client: TestClient, superuser_token_headers: dict[str, str] -) -> None: - """ - Teste para atualizar um ticket que não existe - """ - data = {"title": "Título atualizado", "description": "Descrição atualizada"} - response = client.put( - f"{settings.API_V1_STR}/tickets/{uuid.uuid4()}", - headers=superuser_token_headers, - json=data, - ) - assert response.status_code == 404 - content = response.json() - assert content["detail"] == "Ticket não encontrado" - - -def test_update_ticket_not_enough_permissions( - client: TestClient, normal_user_token_headers: dict[str, str], db: Session -) -> None: - """ - Teste para atualizar um ticket sem permissões suficientes - """ - other_user = create_random_user(db) - ticket = create_random_ticket(db, owner_id=other_user.id) - data = {"title": "Título atualizado", "description": "Descrição atualizada"} - - response = client.put( - f"{settings.API_V1_STR}/tickets/{ticket.id}", - headers=normal_user_token_headers, - json=data, - ) - assert response.status_code == 400 - content = response.json() - assert content["detail"] == "Permissões insuficientes" - - -def test_delete_ticket( - client: TestClient, superuser_token_headers: dict[str, str], db: Session -) -> None: - """ - Teste para excluir um ticket - """ - ticket = create_random_ticket(db) - response = client.delete( - f"{settings.API_V1_STR}/tickets/{ticket.id}", - headers=superuser_token_headers, - ) - assert response.status_code == 200 - content = response.json() - assert content["message"] == "Ticket removido com sucesso" - - -def test_delete_ticket_not_found( - client: TestClient, superuser_token_headers: dict[str, str] -) -> None: - """ - Teste para excluir um ticket que não existe - """ - response = client.delete( - f"{settings.API_V1_STR}/tickets/{uuid.uuid4()}", - headers=superuser_token_headers, - ) - assert response.status_code == 404 - content = response.json() - assert content["detail"] == "Ticket não encontrado" - - -def test_delete_ticket_not_enough_permissions( - client: TestClient, normal_user_token_headers: dict[str, str], db: Session -) -> None: - """ - Teste para excluir um ticket sem permissões suficientes - """ - other_user = create_random_user(db) - ticket = create_random_ticket(db, owner_id=other_user.id) - - response = client.delete( - f"{settings.API_V1_STR}/tickets/{ticket.id}", - headers=normal_user_token_headers, - ) - assert response.status_code == 400 - content = response.json() - assert content["detail"] == "Permissões insuficientes" - - -def test_create_comment( - client: TestClient, superuser_token_headers: dict[str, str], db: Session -) -> None: - """ - Teste para criar um comentário em um ticket - """ - ticket = create_random_ticket(db) - data = {"comment": "Este é um comentário de teste"} - - response = client.post( - f"{settings.API_V1_STR}/tickets/{ticket.id}/comments", - headers=superuser_token_headers, - json=data, - ) - assert response.status_code == 200 - content = response.json() - assert content["comment"] == data["comment"] - assert "id" in content - assert "created_at" in content - - -def test_create_comment_ticket_not_found( - client: TestClient, superuser_token_headers: dict[str, str] -) -> None: - """ - Teste para criar um comentário em um ticket que não existe - """ - data = {"comment": "Este é um comentário de teste"} - - response = client.post( - f"{settings.API_V1_STR}/tickets/{uuid.uuid4()}/comments", - headers=superuser_token_headers, - json=data, - ) - assert response.status_code == 404 - content = response.json() - assert content["detail"] == "Ticket não encontrado" diff --git a/backend/app/tests/utils/ticket.py b/backend/app/tests/utils/ticket.py deleted file mode 100644 index 01434946b1..0000000000 --- a/backend/app/tests/utils/ticket.py +++ /dev/null @@ -1,78 +0,0 @@ -import random -import uuid -from datetime import datetime - -from sqlmodel import Session - -from app.models import ( - Ticket, - Comment, - User, - TicketCategory, - TicketPriority, - TicketStatus -) -from app.tests.utils.user import create_random_user -from app.tests.utils.utils import random_lower_string - - -def create_random_ticket(db: Session, *, owner_id: uuid.UUID | None = None) -> Ticket: - """ - Create a random ticket for testing - """ - if owner_id is None: - user = create_random_user(db) - owner_id = user.id - - title = random_lower_string() - description = random_lower_string() - - # Randomly select category, priority, and status - category = random.choice(list(TicketCategory)) - priority = random.choice(list(TicketPriority)) - status = random.choice(list(TicketStatus)) - - ticket = Ticket( - title=title, - description=description, - category=category, - priority=priority, - status=status, - user_id=owner_id, - created_at=datetime.utcnow(), - updated_at=datetime.utcnow(), - ) - - db.add(ticket) - db.commit() - db.refresh(ticket) - return ticket - - -def create_random_comment( - db: Session, *, ticket_id: uuid.UUID | None = None, user_id: uuid.UUID | None = None -) -> Comment: - """ - Create a random comment for testing - """ - if ticket_id is None: - ticket = create_random_ticket(db) - ticket_id = ticket.id - - if user_id is None: - user = create_random_user(db) - user_id = user.id - - comment_text = random_lower_string() - - comment = Comment( - comment=comment_text, - ticket_id=ticket_id, - user_id=user_id, - created_at=datetime.utcnow(), - ) - - db.add(comment) - db.commit() - db.refresh(comment) - return comment From eddb24defb426f5998c7135fe47619aa2f5e150c Mon Sep 17 00:00:00 2001 From: Marcelo Mizuno Date: Mon, 7 Apr 2025 18:45:08 -0300 Subject: [PATCH 09/19] rota para checar CORS-Origins --- backend/app/api/routes/utils.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/backend/app/api/routes/utils.py b/backend/app/api/routes/utils.py index fc093419b3..06d6b27a38 100644 --- a/backend/app/api/routes/utils.py +++ b/backend/app/api/routes/utils.py @@ -4,6 +4,7 @@ from app.api.deps import get_current_active_superuser from app.models import Message from app.utils import generate_test_email, send_email +from app.core.config import settings router = APIRouter(prefix="/utils", tags=["utils"]) @@ -29,3 +30,10 @@ def test_email(email_to: EmailStr) -> Message: @router.get("/health-check/") async def health_check() -> bool: return True + +@router.get("/cors-origins/") +async def get_cors_origins() -> list[str]: + """ + Get the CORS origins. + """ + return settings.all_cors_origins \ No newline at end of file From baf8766049cede851a16071346de76da62ccb59d Mon Sep 17 00:00:00 2001 From: Marcelo Mizuno Date: Mon, 7 Apr 2025 19:12:32 -0300 Subject: [PATCH 10/19] =?UTF-8?q?Corre=C3=A7=C3=A3o=20de=20relacionamento?= =?UTF-8?q?=20de=20classes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/models.py | 1 + 1 file changed, 1 insertion(+) diff --git a/backend/app/models.py b/backend/app/models.py index 1af3becdf0..c31460dd32 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -47,6 +47,7 @@ class User(UserBase, table=True): id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) hashed_password: str items: list["Item"] = Relationship(back_populates="owner", cascade_delete=True) + tickets: list["Ticket"] = Relationship(back_populates="user", cascade_delete=True) # Properties to return via API, id is always required From 2c208292f2fbde18dd43310a32b32ef9707c3ea9 Mon Sep 17 00:00:00 2001 From: Marcelo Mizuno Date: Mon, 7 Apr 2025 19:46:04 -0300 Subject: [PATCH 11/19] Update Dockerfile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adicionado comando para migração --- backend/Dockerfile | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/backend/Dockerfile b/backend/Dockerfile index 44c53f0365..9c73b3c3de 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -40,4 +40,5 @@ COPY ./app /app/app RUN --mount=type=cache,target=/root/.cache/uv \ uv sync -CMD ["fastapi", "run", "--workers", "4", "app/main.py"] +# Run migrations before starting the server +CMD ["sh", "-c", "alembic upgrade head && uvicorn app.main:app --host 0.0.0.0 --port ${PORT:-8000}"] From 9b22480ad58a1158b7cc3ef2ef28581ff6125397 Mon Sep 17 00:00:00 2001 From: Marcelo Mizuno Date: Mon, 7 Apr 2025 20:06:54 -0300 Subject: [PATCH 12/19] Update Dockerfile --- backend/Dockerfile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/Dockerfile b/backend/Dockerfile index 9c73b3c3de..06d83c76af 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -40,5 +40,5 @@ COPY ./app /app/app RUN --mount=type=cache,target=/root/.cache/uv \ uv sync -# Run migrations before starting the server -CMD ["sh", "-c", "alembic upgrade head && uvicorn app.main:app --host 0.0.0.0 --port ${PORT:-8000}"] +# Run migrations and create superuser before starting the server +CMD ["sh", "-c", "alembic upgrade head && python -c 'from app.main import create_superuser; create_superuser()' && uvicorn app.main:app --host 0.0.0.0 --port ${PORT:-8000}"] From dd39b9f45df2860c70a729cd478d913de1db8505 Mon Sep 17 00:00:00 2001 From: Marcelo Mizuno Date: Mon, 7 Apr 2025 20:12:31 -0300 Subject: [PATCH 13/19] Revert "Update Dockerfile" This reverts commit 9b22480ad58a1158b7cc3ef2ef28581ff6125397. --- backend/Dockerfile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/Dockerfile b/backend/Dockerfile index 06d83c76af..9c73b3c3de 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -40,5 +40,5 @@ COPY ./app /app/app RUN --mount=type=cache,target=/root/.cache/uv \ uv sync -# Run migrations and create superuser before starting the server -CMD ["sh", "-c", "alembic upgrade head && python -c 'from app.main import create_superuser; create_superuser()' && uvicorn app.main:app --host 0.0.0.0 --port ${PORT:-8000}"] +# Run migrations before starting the server +CMD ["sh", "-c", "alembic upgrade head && uvicorn app.main:app --host 0.0.0.0 --port ${PORT:-8000}"] From 6c04c7fc0c99b0db54b351c2c34bbf74304fd271 Mon Sep 17 00:00:00 2001 From: Marcelo Mizuno Date: Sun, 13 Apr 2025 12:53:32 -0300 Subject: [PATCH 14/19] backend/readme.md restored --- backend/README.bak.md | 172 ------------------------ backend/README.md | 299 ++++++++++++++++++++++++------------------ 2 files changed, 172 insertions(+), 299 deletions(-) delete mode 100644 backend/README.bak.md diff --git a/backend/README.bak.md b/backend/README.bak.md deleted file mode 100644 index 17210a2f2c..0000000000 --- a/backend/README.bak.md +++ /dev/null @@ -1,172 +0,0 @@ -# FastAPI Project - Backend - -## Requirements - -* [Docker](https://www.docker.com/). -* [uv](https://docs.astral.sh/uv/) for Python package and environment management. - -## Docker Compose - -Start the local development environment with Docker Compose following the guide in [../development.md](../development.md). - -## General Workflow - -By default, the dependencies are managed with [uv](https://docs.astral.sh/uv/), go there and install it. - -From `./backend/` you can install all the dependencies with: - -```console -$ uv sync -``` - -Then you can activate the virtual environment with: - -```console -$ source .venv/bin/activate -``` - -Make sure your editor is using the correct Python virtual environment, with the interpreter at `backend/.venv/bin/python`. - -Modify or add SQLModel models for data and SQL tables in `./backend/app/models.py`, API endpoints in `./backend/app/api/`, CRUD (Create, Read, Update, Delete) utils in `./backend/app/crud.py`. - -## VS Code - -There are already configurations in place to run the backend through the VS Code debugger, so that you can use breakpoints, pause and explore variables, etc. - -The setup is also already configured so you can run the tests through the VS Code Python tests tab. - -## Docker Compose Override - -During development, you can change Docker Compose settings that will only affect the local development environment in the file `docker-compose.override.yml`. - -The changes to that file only affect the local development environment, not the production environment. So, you can add "temporary" changes that help the development workflow. - -For example, the directory with the backend code is synchronized in the Docker container, copying the code you change live to the directory inside the container. That allows you to test your changes right away, without having to build the Docker image again. It should only be done during development, for production, you should build the Docker image with a recent version of the backend code. But during development, it allows you to iterate very fast. - -There is also a command override that runs `fastapi run --reload` instead of the default `fastapi run`. It starts a single server process (instead of multiple, as would be for production) and reloads the process whenever the code changes. Have in mind that if you have a syntax error and save the Python file, it will break and exit, and the container will stop. After that, you can restart the container by fixing the error and running again: - -```console -$ docker compose watch -``` - -There is also a commented out `command` override, you can uncomment it and comment the default one. It makes the backend container run a process that does "nothing", but keeps the container alive. That allows you to get inside your running container and execute commands inside, for example a Python interpreter to test installed dependencies, or start the development server that reloads when it detects changes. - -To get inside the container with a `bash` session you can start the stack with: - -```console -$ docker compose watch -``` - -and then in another terminal, `exec` inside the running container: - -```console -$ docker compose exec backend bash -``` - -You should see an output like: - -```console -root@7f2607af31c3:/app# -``` - -that means that you are in a `bash` session inside your container, as a `root` user, under the `/app` directory, this directory has another directory called "app" inside, that's where your code lives inside the container: `/app/app`. - -There you can use the `fastapi run --reload` command to run the debug live reloading server. - -```console -$ fastapi run --reload app/main.py -``` - -...it will look like: - -```console -root@7f2607af31c3:/app# fastapi run --reload app/main.py -``` - -and then hit enter. That runs the live reloading server that auto reloads when it detects code changes. - -Nevertheless, if it doesn't detect a change but a syntax error, it will just stop with an error. But as the container is still alive and you are in a Bash session, you can quickly restart it after fixing the error, running the same command ("up arrow" and "Enter"). - -...this previous detail is what makes it useful to have the container alive doing nothing and then, in a Bash session, make it run the live reload server. - -## Backend tests - -To test the backend run: - -```console -$ bash ./scripts/test.sh -``` - -The tests run with Pytest, modify and add tests to `./backend/app/tests/`. - -If you use GitHub Actions the tests will run automatically. - -### Test running stack - -If your stack is already up and you just want to run the tests, you can use: - -```bash -docker compose exec backend bash scripts/tests-start.sh -``` - -That `/app/scripts/tests-start.sh` script just calls `pytest` after making sure that the rest of the stack is running. If you need to pass extra arguments to `pytest`, you can pass them to that command and they will be forwarded. - -For example, to stop on first error: - -```bash -docker compose exec backend bash scripts/tests-start.sh -x -``` - -### Test Coverage - -When the tests are run, a file `htmlcov/index.html` is generated, you can open it in your browser to see the coverage of the tests. - -## Migrations - -As during local development your app directory is mounted as a volume inside the container, you can also run the migrations with `alembic` commands inside the container and the migration code will be in your app directory (instead of being only inside the container). So you can add it to your git repository. - -Make sure you create a "revision" of your models and that you "upgrade" your database with that revision every time you change them. As this is what will update the tables in your database. Otherwise, your application will have errors. - -* Start an interactive session in the backend container: - -```console -$ docker compose exec backend bash -``` - -* Alembic is already configured to import your SQLModel models from `./backend/app/models.py`. - -* After changing a model (for example, adding a column), inside the container, create a revision, e.g.: - -```console -$ alembic revision --autogenerate -m "Add column last_name to User model" -``` - -* Commit to the git repository the files generated in the alembic directory. - -* After creating the revision, run the migration in the database (this is what will actually change the database): - -```console -$ alembic upgrade head -``` - -If you don't want to use migrations at all, uncomment the lines in the file at `./backend/app/core/db.py` that end in: - -```python -SQLModel.metadata.create_all(engine) -``` - -and comment the line in the file `scripts/prestart.sh` that contains: - -```console -$ alembic upgrade head -``` - -If you don't want to start with the default models and want to remove them / modify them, from the beginning, without having any previous revision, you can remove the revision files (`.py` Python files) under `./backend/app/alembic/versions/`. And then create a first migration as described above. - -## Email Templates - -The email templates are in `./backend/app/email-templates/`. Here, there are two directories: `build` and `src`. The `src` directory contains the source files that are used to build the final email templates. The `build` directory contains the final email templates that are used by the application. - -Before continuing, ensure you have the [MJML extension](https://marketplace.visualstudio.com/items?itemName=attilabuti.vscode-mjml) installed in your VS Code. - -Once you have the MJML extension installed, you can create a new email template in the `src` directory. After creating the new email template and with the `.mjml` file open in your editor, open the command palette with `Ctrl+Shift+P` and search for `MJML: Export to HTML`. This will convert the `.mjml` file to a `.html` file and now you can save it in the build directory. diff --git a/backend/README.md b/backend/README.md index 601009752a..17210a2f2c 100644 --- a/backend/README.md +++ b/backend/README.md @@ -1,127 +1,172 @@ -# Banco de Dados - - -### Tickets -- **id** -- **title** -- **description** -- **category** (Suporte, Manutenção, Dúvida) -- **priority** (Baixa, Média, Alta) -- **status** (Aberto, Em andamento, Fechado) -- **user_id** -- **created_at** -- **updated_at** - -### Comments -- **id** -- **ticket_id** -- **user_id** -- **comment** -- **created_at** - ---- - -# Endpoints - - -## GET /tickets -- **Descrição**: Listar todos os tickets (com filtros e paginação). -- **Exemplo de Response**: -{ - "tickets": [ - { - "id": 1, - "title": "Erro no sistema", - "status": "Aberto", - "priority": "Alta", - "created_at": "2023-10-01T12:00:00Z" - }, - { - "id": 2, - "title": "Problema com login", - "status": "Em andamento", - "priority": "Média", - "created_at": "2023-10-02T08:30:00Z" - } - ], - "page": 1, - "total": 2 -} - -## GET /tickets/{id} -- **Descrição**: Retorna detalhes de um ticket. -- **Exemplo de Response**: -{ - "id": 1, - "title": "Erro no sistema", - "description": "Detalhes do erro ocorrido...", - "category": "Suporte", - "priority": "Alta", - "status": "Aberto", - "created_at": "2023-10-01T12:00:00Z", - "updated_at": "2023-10-01T12:00:00Z", - "comments": [ - { - "id": 1, - "comment": "Identificado o problema inicial.", - "created_at": "2023-10-01T12:30:00Z" - } - ] -} - -## POST /tickets -- **Descrição**: Criar um novo ticket. -- **Request Body**: -{ - "title": "Novo ticket", - "description": "Detalhes do ticket...", - "category": "Manutenção", - "priority": "Média" -} -- **Exemplo de Response**: -{ - "id": 3, - "title": "Novo ticket", - "description": "Detalhes do ticket...", - "category": "Manutenção", - "priority": "Média", - "status": "Aberto", - "created_at": "2023-10-05T10:00:00Z", - "updated_at": "2023-10-05T10:00:00Z" -} - -## PUT /tickets/{id} -- **Descrição**: Atualizar um ticket existente. -- **Request Body**: -{ - "title": "Ticket atualizado", - "description": "Descrição atualizada...", - "category": "Suporte", - "priority": "Alta", - "status": "Em andamento" -} -- **Exemplo de Response**: -{ - "id": 1, - "title": "Ticket atualizado", - "description": "Descrição atualizada...", - "category": "Suporte", - "priority": "Alta", - "status": "Em andamento", - "created_at": "2023-10-01T12:00:00Z", - "updated_at": "2023-10-05T10:00:00Z" -} - -## POST /tickets/{id}/comments -- **Descrição**: Adicionar comentário a um ticket. -- **Request Body**: -{ - "comment": "Comentário adicionado ao ticket." -} -- **Exemplo de Response**: -{ - "id": 5, - "ticket_id": 1, - "comment": "Comentário adicionado ao ticket.", - "created_at": "2023-10-05T10:30:00Z" -} \ No newline at end of file +# FastAPI Project - Backend + +## Requirements + +* [Docker](https://www.docker.com/). +* [uv](https://docs.astral.sh/uv/) for Python package and environment management. + +## Docker Compose + +Start the local development environment with Docker Compose following the guide in [../development.md](../development.md). + +## General Workflow + +By default, the dependencies are managed with [uv](https://docs.astral.sh/uv/), go there and install it. + +From `./backend/` you can install all the dependencies with: + +```console +$ uv sync +``` + +Then you can activate the virtual environment with: + +```console +$ source .venv/bin/activate +``` + +Make sure your editor is using the correct Python virtual environment, with the interpreter at `backend/.venv/bin/python`. + +Modify or add SQLModel models for data and SQL tables in `./backend/app/models.py`, API endpoints in `./backend/app/api/`, CRUD (Create, Read, Update, Delete) utils in `./backend/app/crud.py`. + +## VS Code + +There are already configurations in place to run the backend through the VS Code debugger, so that you can use breakpoints, pause and explore variables, etc. + +The setup is also already configured so you can run the tests through the VS Code Python tests tab. + +## Docker Compose Override + +During development, you can change Docker Compose settings that will only affect the local development environment in the file `docker-compose.override.yml`. + +The changes to that file only affect the local development environment, not the production environment. So, you can add "temporary" changes that help the development workflow. + +For example, the directory with the backend code is synchronized in the Docker container, copying the code you change live to the directory inside the container. That allows you to test your changes right away, without having to build the Docker image again. It should only be done during development, for production, you should build the Docker image with a recent version of the backend code. But during development, it allows you to iterate very fast. + +There is also a command override that runs `fastapi run --reload` instead of the default `fastapi run`. It starts a single server process (instead of multiple, as would be for production) and reloads the process whenever the code changes. Have in mind that if you have a syntax error and save the Python file, it will break and exit, and the container will stop. After that, you can restart the container by fixing the error and running again: + +```console +$ docker compose watch +``` + +There is also a commented out `command` override, you can uncomment it and comment the default one. It makes the backend container run a process that does "nothing", but keeps the container alive. That allows you to get inside your running container and execute commands inside, for example a Python interpreter to test installed dependencies, or start the development server that reloads when it detects changes. + +To get inside the container with a `bash` session you can start the stack with: + +```console +$ docker compose watch +``` + +and then in another terminal, `exec` inside the running container: + +```console +$ docker compose exec backend bash +``` + +You should see an output like: + +```console +root@7f2607af31c3:/app# +``` + +that means that you are in a `bash` session inside your container, as a `root` user, under the `/app` directory, this directory has another directory called "app" inside, that's where your code lives inside the container: `/app/app`. + +There you can use the `fastapi run --reload` command to run the debug live reloading server. + +```console +$ fastapi run --reload app/main.py +``` + +...it will look like: + +```console +root@7f2607af31c3:/app# fastapi run --reload app/main.py +``` + +and then hit enter. That runs the live reloading server that auto reloads when it detects code changes. + +Nevertheless, if it doesn't detect a change but a syntax error, it will just stop with an error. But as the container is still alive and you are in a Bash session, you can quickly restart it after fixing the error, running the same command ("up arrow" and "Enter"). + +...this previous detail is what makes it useful to have the container alive doing nothing and then, in a Bash session, make it run the live reload server. + +## Backend tests + +To test the backend run: + +```console +$ bash ./scripts/test.sh +``` + +The tests run with Pytest, modify and add tests to `./backend/app/tests/`. + +If you use GitHub Actions the tests will run automatically. + +### Test running stack + +If your stack is already up and you just want to run the tests, you can use: + +```bash +docker compose exec backend bash scripts/tests-start.sh +``` + +That `/app/scripts/tests-start.sh` script just calls `pytest` after making sure that the rest of the stack is running. If you need to pass extra arguments to `pytest`, you can pass them to that command and they will be forwarded. + +For example, to stop on first error: + +```bash +docker compose exec backend bash scripts/tests-start.sh -x +``` + +### Test Coverage + +When the tests are run, a file `htmlcov/index.html` is generated, you can open it in your browser to see the coverage of the tests. + +## Migrations + +As during local development your app directory is mounted as a volume inside the container, you can also run the migrations with `alembic` commands inside the container and the migration code will be in your app directory (instead of being only inside the container). So you can add it to your git repository. + +Make sure you create a "revision" of your models and that you "upgrade" your database with that revision every time you change them. As this is what will update the tables in your database. Otherwise, your application will have errors. + +* Start an interactive session in the backend container: + +```console +$ docker compose exec backend bash +``` + +* Alembic is already configured to import your SQLModel models from `./backend/app/models.py`. + +* After changing a model (for example, adding a column), inside the container, create a revision, e.g.: + +```console +$ alembic revision --autogenerate -m "Add column last_name to User model" +``` + +* Commit to the git repository the files generated in the alembic directory. + +* After creating the revision, run the migration in the database (this is what will actually change the database): + +```console +$ alembic upgrade head +``` + +If you don't want to use migrations at all, uncomment the lines in the file at `./backend/app/core/db.py` that end in: + +```python +SQLModel.metadata.create_all(engine) +``` + +and comment the line in the file `scripts/prestart.sh` that contains: + +```console +$ alembic upgrade head +``` + +If you don't want to start with the default models and want to remove them / modify them, from the beginning, without having any previous revision, you can remove the revision files (`.py` Python files) under `./backend/app/alembic/versions/`. And then create a first migration as described above. + +## Email Templates + +The email templates are in `./backend/app/email-templates/`. Here, there are two directories: `build` and `src`. The `src` directory contains the source files that are used to build the final email templates. The `build` directory contains the final email templates that are used by the application. + +Before continuing, ensure you have the [MJML extension](https://marketplace.visualstudio.com/items?itemName=attilabuti.vscode-mjml) installed in your VS Code. + +Once you have the MJML extension installed, you can create a new email template in the `src` directory. After creating the new email template and with the `.mjml` file open in your editor, open the command palette with `Ctrl+Shift+P` and search for `MJML: Export to HTML`. This will convert the `.mjml` file to a `.html` file and now you can save it in the build directory. From e94eb72228e3c78c6bb272eb4ead941c0a5070bc Mon Sep 17 00:00:00 2001 From: Marcelo Mizuno Date: Sun, 13 Apr 2025 19:03:46 -0300 Subject: [PATCH 15/19] Update db.py --- backend/app/core/db.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/app/core/db.py b/backend/app/core/db.py index ba991fb36d..63c7ca013d 100644 --- a/backend/app/core/db.py +++ b/backend/app/core/db.py @@ -16,10 +16,10 @@ def init_db(session: Session) -> None: # Tables should be created with Alembic migrations # But if you don't want to use migrations, create # the tables un-commenting the next lines - # from sqlmodel import SQLModel + from sqlmodel import SQLModel # This works because the models are already imported and registered from app.models - # SQLModel.metadata.create_all(engine) + SQLModel.metadata.create_all(engine) user = session.exec( select(User).where(User.email == settings.FIRST_SUPERUSER) From 7f5a3d47d4743b13c7502bce6efa71a8d84d67b7 Mon Sep 17 00:00:00 2001 From: Marcelo Mizuno Date: Sun, 13 Apr 2025 19:33:01 -0300 Subject: [PATCH 16/19] Update db.py --- backend/app/core/db.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/app/core/db.py b/backend/app/core/db.py index 63c7ca013d..ba991fb36d 100644 --- a/backend/app/core/db.py +++ b/backend/app/core/db.py @@ -16,10 +16,10 @@ def init_db(session: Session) -> None: # Tables should be created with Alembic migrations # But if you don't want to use migrations, create # the tables un-commenting the next lines - from sqlmodel import SQLModel + # from sqlmodel import SQLModel # This works because the models are already imported and registered from app.models - SQLModel.metadata.create_all(engine) + # SQLModel.metadata.create_all(engine) user = session.exec( select(User).where(User.email == settings.FIRST_SUPERUSER) From b02a77fe2686e8e6bb78d85f7c6feba8c266d6b6 Mon Sep 17 00:00:00 2001 From: Marcelo Mizuno Date: Sun, 13 Apr 2025 20:04:08 -0300 Subject: [PATCH 17/19] Update db.py --- backend/app/core/db.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/app/core/db.py b/backend/app/core/db.py index ba991fb36d..63c7ca013d 100644 --- a/backend/app/core/db.py +++ b/backend/app/core/db.py @@ -16,10 +16,10 @@ def init_db(session: Session) -> None: # Tables should be created with Alembic migrations # But if you don't want to use migrations, create # the tables un-commenting the next lines - # from sqlmodel import SQLModel + from sqlmodel import SQLModel # This works because the models are already imported and registered from app.models - # SQLModel.metadata.create_all(engine) + SQLModel.metadata.create_all(engine) user = session.exec( select(User).where(User.email == settings.FIRST_SUPERUSER) From ad1f58bfbe556d1a9528db631f33379d2567d402 Mon Sep 17 00:00:00 2001 From: Marcelo Mizuno Date: Sun, 13 Apr 2025 20:56:38 -0300 Subject: [PATCH 18/19] Migration fix --- .../f23a9c45d178_add_ticket_and_comment.py | 68 +++++++++++++++++++ backend/app/core/db.py | 4 +- 2 files changed, 70 insertions(+), 2 deletions(-) create mode 100644 backend/app/alembic/versions/f23a9c45d178_add_ticket_and_comment.py diff --git a/backend/app/alembic/versions/f23a9c45d178_add_ticket_and_comment.py b/backend/app/alembic/versions/f23a9c45d178_add_ticket_and_comment.py new file mode 100644 index 0000000000..7dfd9794fc --- /dev/null +++ b/backend/app/alembic/versions/f23a9c45d178_add_ticket_and_comment.py @@ -0,0 +1,68 @@ +""" +Add ticket and comment tables +""" +from alembic import op +import sqlalchemy as sa +import sqlmodel.sql.sqltypes +from sqlalchemy.dialects.postgresql import UUID +from uuid import uuid4 + + +# revision identifiers, used by Alembic. +revision = 'f23a9c45d178' +down_revision = '1a31ce608336' +branch_labels = None +depends_on = None + + +def upgrade(): + # Create ticket table + op.create_table( + 'ticket', + sa.Column("id", UUID(), nullable=False, server_default=sa.text("gen_random_uuid()")), + sa.Column("title", sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column("description", sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column("status", sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column("priority", sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.text("now()")), + sa.Column("updated_at", sa.DateTime(), nullable=True), + sa.Column("owner_id", UUID(), nullable=False), + sa.PrimaryKeyConstraint("id"), + sa.ForeignKeyConstraint( + ["owner_id"], ["user.id"], ondelete="CASCADE" + ), + ) + + # Create index for ticket lookup by owner + op.create_index(op.f('ix_ticket_owner_id'), 'ticket', ['owner_id'], unique=False) + + # Create comment table + op.create_table( + 'comment', + sa.Column("id", UUID(), nullable=False, server_default=sa.text("gen_random_uuid()")), + sa.Column("content", sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.text("now()")), + sa.Column("ticket_id", UUID(), nullable=False), + sa.Column("user_id", UUID(), nullable=False), + sa.PrimaryKeyConstraint("id"), + sa.ForeignKeyConstraint( + ["ticket_id"], ["ticket.id"], ondelete="CASCADE" + ), + sa.ForeignKeyConstraint( + ["user_id"], ["user.id"], ondelete="CASCADE" + ), + ) + + # Create indexes for faster comment lookups + op.create_index(op.f('ix_comment_ticket_id'), 'comment', ['ticket_id'], unique=False) + op.create_index(op.f('ix_comment_user_id'), 'comment', ['user_id'], unique=False) + + +def downgrade(): + # Drop tables in reverse order (comments first, then tickets) + op.drop_index(op.f('ix_comment_user_id'), table_name='comment') + op.drop_index(op.f('ix_comment_ticket_id'), table_name='comment') + op.drop_table('comment') + + op.drop_index(op.f('ix_ticket_owner_id'), table_name='ticket') + op.drop_table('ticket') diff --git a/backend/app/core/db.py b/backend/app/core/db.py index 63c7ca013d..ba991fb36d 100644 --- a/backend/app/core/db.py +++ b/backend/app/core/db.py @@ -16,10 +16,10 @@ def init_db(session: Session) -> None: # Tables should be created with Alembic migrations # But if you don't want to use migrations, create # the tables un-commenting the next lines - from sqlmodel import SQLModel + # from sqlmodel import SQLModel # This works because the models are already imported and registered from app.models - SQLModel.metadata.create_all(engine) + # SQLModel.metadata.create_all(engine) user = session.exec( select(User).where(User.email == settings.FIRST_SUPERUSER) From 84659d255ba9c2fff76e8a723083bac80f249082 Mon Sep 17 00:00:00 2001 From: Marcelo Mizuno Date: Sun, 13 Apr 2025 21:11:16 -0300 Subject: [PATCH 19/19] Add category to ticket table (bd) --- .../b54d6e812a9c_add_category_to_ticket.py | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 backend/app/alembic/versions/b54d6e812a9c_add_category_to_ticket.py diff --git a/backend/app/alembic/versions/b54d6e812a9c_add_category_to_ticket.py b/backend/app/alembic/versions/b54d6e812a9c_add_category_to_ticket.py new file mode 100644 index 0000000000..1df647fe34 --- /dev/null +++ b/backend/app/alembic/versions/b54d6e812a9c_add_category_to_ticket.py @@ -0,0 +1,24 @@ +""" +Add category to ticket table +""" +from alembic import op +import sqlalchemy as sa +import sqlmodel.sql.sqltypes + + +# revision identifiers, used by Alembic. +revision = 'b54d6e812a9c' +down_revision = 'f23a9c45d178' +branch_labels = None +depends_on = None + + +def upgrade(): + # Add category column to ticket table + op.add_column('ticket', sa.Column('category', sqlmodel.sql.sqltypes.AutoString(), nullable=False, + server_default="Suporte")) # Default to "Suporte" for existing tickets + + +def downgrade(): + # Remove category column from ticket table + op.drop_column('ticket', 'category')