87 | >(({ className, ...props }, ref) => (
88 | [role=checkbox]]:translate-y-[2px]",
92 | className
93 | )}
94 | {...props}
95 | />
96 | ))
97 | TableCell.displayName = "TableCell"
98 |
99 | const TableCaption = React.forwardRef<
100 | HTMLTableCaptionElement,
101 | React.HTMLAttributes
102 | >(({ className, ...props }, ref) => (
103 |
108 | ))
109 | TableCaption.displayName = "TableCaption"
110 |
111 | export {
112 | Table,
113 | TableHeader,
114 | TableBody,
115 | TableFooter,
116 | TableHead,
117 | TableRow,
118 | TableCell,
119 | TableCaption,
120 | }
121 |
--------------------------------------------------------------------------------
/resources/components/user-nav.tsx:
--------------------------------------------------------------------------------
1 | import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
2 | import { Button } from "@/components/ui/button"
3 | import {
4 | DropdownMenu,
5 | DropdownMenuContent,
6 | DropdownMenuGroup,
7 | DropdownMenuItem,
8 | DropdownMenuLabel,
9 | DropdownMenuSeparator,
10 | DropdownMenuShortcut,
11 | DropdownMenuTrigger,
12 | } from "@/components/ui/dropdown-menu"
13 |
14 | export function UserNav() {
15 | return (
16 |
17 |
18 |
19 |
20 |
21 | SC
22 |
23 |
24 |
25 |
26 |
27 |
28 |
shadcn
29 |
30 | m@example.com
31 |
32 |
33 |
34 |
35 |
36 |
37 | Profile
38 | ⇧⌘P
39 |
40 |
41 | Billing
42 | ⌘B
43 |
44 |
45 | Settings
46 | ⌘S
47 |
48 | New Team
49 |
50 |
51 |
52 | Log out
53 | ⇧⌘Q
54 |
55 |
56 |
57 | )
58 | }
59 |
--------------------------------------------------------------------------------
/resources/contexts/AuthProvider.tsx:
--------------------------------------------------------------------------------
1 | import { toast } from "sonner"
2 | import axios from "axios"
3 | import { jwtDecode } from "jwt-decode"
4 | import { createContext, useContext, useEffect, useState } from "react"
5 | import { useNavigate } from "react-router-dom"
6 |
7 | type User = {
8 | id: string
9 | name: string
10 | email: string
11 | createdAt: string
12 | }
13 |
14 | interface handleLoginProps {
15 | token: string
16 | user: User | null
17 | }
18 |
19 | interface AuthStateProps {
20 | user: User | null
21 | token: string
22 | }
23 |
24 | const AuthContext = createContext(null)
25 |
26 | export const useAuth = () => {
27 | return useContext(AuthContext)
28 | }
29 |
30 | const AuthProvider = ({ children }: { children: React.ReactNode }) => {
31 | const navigate = useNavigate()
32 | const [auth, setAuth] = useState({
33 | user: null,
34 | token: "",
35 | })
36 |
37 | axios.defaults.headers.common["Authorization"] = auth?.token || ""
38 |
39 | useEffect(() => {
40 | const storedAuth = JSON.parse(localStorage.getItem("auth")!)
41 | if (storedAuth) {
42 | const decodedToken = jwtDecode(storedAuth.token)
43 | const expiresAt = decodedToken?.exp
44 | const currentTime = Math.floor(Date.now() / 1000)
45 | if (expiresAt! <= currentTime) {
46 | setAuth({ user: null, token: "" })
47 | localStorage.removeItem("auth")
48 | return
49 | } else {
50 | setAuth({
51 | user: storedAuth.user,
52 | token: storedAuth.token,
53 | })
54 | }
55 | }
56 | }, [])
57 |
58 | const handleLogin = ({ token, user }: handleLoginProps) => {
59 | setAuth({ user, token })
60 | localStorage.setItem("auth", JSON.stringify({ user, token }))
61 | return
62 | }
63 |
64 | const handleLogout = () => {
65 | setAuth({ user: null, token: "" })
66 | localStorage.removeItem("auth")
67 | toast("Logged out successfully")
68 | navigate("/login")
69 | return
70 | }
71 |
72 | return (
73 |
81 | {children}
82 |
83 | )
84 | }
85 |
86 | export default AuthProvider
87 |
--------------------------------------------------------------------------------
/resources/layouts/AuthLayout.tsx:
--------------------------------------------------------------------------------
1 | import favicon from "@/assets/favicon.png"
2 | interface AuthLayoutProps {
3 | children: React.ReactNode
4 | title: string
5 | description: string
6 | keywords: string
7 | }
8 |
9 | const helmetContext = {}
10 |
11 | const AuthLayout = ({
12 | children,
13 | title,
14 | description,
15 | keywords,
16 | }: AuthLayoutProps) => {
17 | return (
18 | <>
19 |
20 |
21 |
22 |
23 | {title}
24 | {children}
25 | >
26 | )
27 | }
28 |
29 | AuthLayout.defaultProps = {
30 | title: "Litestar Fullstack Application",
31 | description: "A fullstack reference application",
32 | keywords: "litestar",
33 | }
34 |
35 | export default AuthLayout
36 |
--------------------------------------------------------------------------------
/resources/layouts/MainLayout.tsx:
--------------------------------------------------------------------------------
1 | import favicon from "@/assets/favicon.png"
2 | interface MainLayoutProps {
3 | children: React.ReactNode
4 | title: string
5 | description: string
6 | keywords: string
7 | }
8 |
9 | const MainLayout = ({
10 | children,
11 | title,
12 | description,
13 | keywords,
14 | }: MainLayoutProps) => {
15 | return (
16 | <>
17 |
18 |
19 |
20 |
21 | {title}
22 |
23 | {children}
24 |
25 | >
26 | )
27 | }
28 |
29 | MainLayout.defaultProps = {
30 | title: "Litestar Fullstack Application",
31 | description: "A fullstack reference application",
32 | keywords: "litestar",
33 | }
34 |
35 | export default MainLayout
36 |
--------------------------------------------------------------------------------
/resources/lib/protected-routes.tsx:
--------------------------------------------------------------------------------
1 | import { useAuth } from "@/contexts/AuthProvider"
2 | import { useEffect } from "react"
3 | import { Outlet, useNavigate } from "react-router-dom"
4 |
5 | const ProtectedRoutes: React.FC = () => {
6 | const { auth } = useAuth()
7 | const navigate = useNavigate()
8 |
9 | useEffect(() => {
10 | if (!auth.token) {
11 | return navigate("/login")
12 | }
13 | }, [auth])
14 |
15 | return
16 | }
17 |
18 | export default ProtectedRoutes
19 |
--------------------------------------------------------------------------------
/resources/lib/utils.ts:
--------------------------------------------------------------------------------
1 | import { type ClassValue, clsx } from "clsx"
2 | import { twMerge } from "tailwind-merge"
3 |
4 | export function cn(...inputs: ClassValue[]) {
5 | return twMerge(clsx(inputs))
6 | }
7 |
--------------------------------------------------------------------------------
/resources/main.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | @layer base {
6 | :root {
7 | --background: 0 0% 100%;
8 | --foreground: 240 10% 3.9%;
9 | --card: 0 0% 100%;
10 | --card-foreground: 240 10% 3.9%;
11 | --popover: 0 0% 100%;
12 | --popover-foreground: 240 10% 3.9%;
13 | --primary: 346.8 77.2% 49.8%;
14 | --primary-foreground: 355.7 100% 97.3%;
15 | --secondary: 240 4.8% 95.9%;
16 | --secondary-foreground: 240 5.9% 10%;
17 | --muted: 240 4.8% 95.9%;
18 | --muted-foreground: 240 3.8% 46.1%;
19 | --accent: 240 4.8% 95.9%;
20 | --accent-foreground: 240 5.9% 10%;
21 | --destructive: 0 84.2% 60.2%;
22 | --destructive-foreground: 0 0% 98%;
23 | --border: 240 5.9% 90%;
24 | --input: 240 5.9% 90%;
25 | --ring: 346.8 77.2% 49.8%;
26 | --radius: 0.5rem;
27 | }
28 |
29 | .dark {
30 | --background: 20 14.3% 4.1%;
31 | --foreground: 0 0% 95%;
32 | --card: 24 9.8% 10%;
33 | --card-foreground: 0 0% 95%;
34 | --popover: 0 0% 9%;
35 | --popover-foreground: 0 0% 95%;
36 | --primary: 346.8 77.2% 49.8%;
37 | --primary-foreground: 355.7 100% 97.3%;
38 | --secondary: 240 3.7% 15.9%;
39 | --secondary-foreground: 0 0% 98%;
40 | --muted: 0 0% 15%;
41 | --muted-foreground: 240 5% 64.9%;
42 | --accent: 12 6.5% 15.1%;
43 | --accent-foreground: 0 0% 98%;
44 | --destructive: 0 62.8% 30.6%;
45 | --destructive-foreground: 0 85.7% 97.3%;
46 | --border: 240 3.7% 15.9%;
47 | --input: 240 3.7% 15.9%;
48 | --ring: 346.8 77.2% 49.8%;
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/resources/main.tsx:
--------------------------------------------------------------------------------
1 | import "vite/modulepreload-polyfill"
2 |
3 | import React from "react"
4 | import ReactDOM from "react-dom/client"
5 | import App from "@/App.tsx"
6 | import "@/main.css"
7 | import { BrowserRouter } from "react-router-dom"
8 | import AuthProvider from "@/contexts/AuthProvider.tsx"
9 | import { Toaster } from "@/components/ui/sonner"
10 |
11 | ReactDOM.createRoot(document.getElementById("root")!).render(
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 | )
21 |
--------------------------------------------------------------------------------
/resources/pages/Home.tsx:
--------------------------------------------------------------------------------
1 | import { useAuth } from "@/contexts/AuthProvider"
2 | import MainLayout from "@/layouts/MainLayout"
3 | import { useEffect } from "react"
4 | import { TeamSwitcher } from "@/components/team-switcher"
5 | import { MainNav } from "@/components/main-nav"
6 | import { UserNav } from "@/components/user-nav"
7 |
8 | const Home: React.FC = () => {
9 | const { auth } = useAuth()
10 |
11 | useEffect(() => {}, [auth?.token])
12 |
13 | return (
14 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 | )
32 | }
33 |
34 | export default Home
35 |
--------------------------------------------------------------------------------
/resources/pages/PageNotFound.tsx:
--------------------------------------------------------------------------------
1 | import MainLayout from "@/layouts/MainLayout"
2 | import { useNavigate } from "react-router-dom"
3 |
4 | const PageNotFound: React.FC = () => {
5 | const navigate = useNavigate()
6 |
7 | return (
8 |
13 |
14 |
15 |
20 |
21 |
404 error
22 |
23 | We can't find that page
24 |
25 |
26 | Sorry, the page you are looking for doesn't exist or has been
27 | moved.
28 |
29 |
30 | navigate("/")}
32 | type="button"
33 | className="inline-flex items-center rounded-md border border-black px-3 py-2 text-sm font-semibold text-black shadow-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-black transition-transform active:scale-95"
34 | >
35 | Go back
36 |
37 |
38 |
39 |
40 |
41 |
42 | )
43 | }
44 |
45 | export default PageNotFound
46 |
--------------------------------------------------------------------------------
/resources/pages/Placeholder.tsx:
--------------------------------------------------------------------------------
1 | import MainLayout from "@/layouts/MainLayout"
2 | import { useNavigate } from "react-router-dom"
3 |
4 | const PageNotFound: React.FC = () => {
5 | const navigate = useNavigate()
6 |
7 | return (
8 |
13 |
14 |
15 |
20 |
21 |
22 | Under Construction
23 |
24 |
25 | We are working on this page
26 |
27 |
28 | Sorry, the page you are looking for doesn't exist or has been
29 | moved.
30 |
31 |
32 | navigate("/")}
34 | type="button"
35 | className="inline-flex items-center rounded-md border border-black px-3 py-2 text-sm font-semibold text-black shadow-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-black transition-transform active:scale-95"
36 | >
37 | Go back
38 |
39 |
40 |
41 |
42 |
43 |
44 | )
45 | }
46 |
47 | export default PageNotFound
48 |
--------------------------------------------------------------------------------
/resources/pages/access/Register.tsx:
--------------------------------------------------------------------------------
1 | import { cn } from "@/lib/utils"
2 | import AuthLayout from "@/layouts/AuthLayout"
3 | import { buttonVariants } from "@/components/ui/button"
4 | import { UserRegistrationForm } from "./components/user-registration-form"
5 | import { Link } from "react-router-dom"
6 | export default function AuthenticationPage() {
7 | return (
8 |
13 |
14 |
21 | Login
22 |
23 |
24 |
25 |
26 |
36 |
37 |
38 | Litestar Fullstack Application
39 |
40 |
41 |
42 |
43 | “This library has saved me countless hours of assessment
44 | work and helped me identify the best databases for us to start
45 | our migration journey with.”
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 | Create a Fullstack Account
56 |
57 |
58 | Enter your information below to create an account
59 |
60 |
61 |
62 |
63 | By clicking continue, you agree to our{" "}
64 |
68 | Terms of Service
69 | {" "}
70 | and{" "}
71 |
75 | Privacy Policy
76 |
77 | .
78 |
79 |
80 |
81 |
82 |
83 | )
84 | }
85 |
--------------------------------------------------------------------------------
/resources/services/auth.ts:
--------------------------------------------------------------------------------
1 | import axios from "axios"
2 | import { API } from "@/types/api"
3 | const APP_URL = import.meta.env.APP_URL || ""
4 | export const registerUserService = async (data: any) => {
5 | try {
6 | const response = await axios.post(
7 | `${APP_URL}/api/access/signup`,
8 | data
9 | )
10 | return response.data
11 | } catch (error) {
12 | throw error
13 | }
14 | }
15 |
16 | export const loginUserService = async (data: any) => {
17 | try {
18 | return await axios.post(
19 | `${APP_URL}/api/access/login`,
20 | data,
21 | {
22 | headers: { "Content-Type": "application/x-www-form-urlencoded" },
23 | }
24 | )
25 | } catch (error) {
26 | throw error
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/resources/services/profile.ts:
--------------------------------------------------------------------------------
1 | import axios from "axios"
2 | import { API } from "@/types/api"
3 | const APP_URL = import.meta.env.APP_URL || ""
4 | export const getUserProfileService = async (data: any) => {
5 | try {
6 | const response = await axios.post(
7 | `${APP_URL}/api/access/signup`,
8 | data
9 | )
10 | return response.data
11 | } catch (error) {
12 | throw error
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/resources/types/nav.ts:
--------------------------------------------------------------------------------
1 | import { Icons } from "@/components/icons"
2 |
3 | export interface NavItem {
4 | title: string
5 | href?: string
6 | disabled?: boolean
7 | external?: boolean
8 | icon?: keyof typeof Icons
9 | label?: string
10 | }
11 |
12 | export interface NavItemWithChildren extends NavItem {
13 | items: NavItemWithChildren[]
14 | }
15 |
16 | export interface MainNavItem extends NavItem {}
17 |
18 | export interface SidebarNavItem extends NavItemWithChildren {}
19 |
--------------------------------------------------------------------------------
/resources/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/sonar-project.properties:
--------------------------------------------------------------------------------
1 | # Path to sources
2 | sonar.sources=src/
3 | sonar.exclusions=tools/, deploy/, bin/, artwork/, docs/
4 | #sonar.inclusions=
5 | sonar.python.coverage.reportPaths=coverage.xml
6 | sonar.python.version=3.11, 3.12, 3.13
7 | sonar.tests=tests
8 | sonar.coverage.exclusions=\
9 | **/__init__.py, \
10 | src/app/db/migrations/versions/*.py, \
11 | src/app/db/migrations/*.py, \
12 | tests/*.py
13 | sonar.sourceEncoding=UTF-8
14 |
15 | # Exclusions for copy-paste detection
16 | sonar.cpd.exclusions=tools/*, deploy/*, bin/*, artwork/*, docs/*, resources/*, src/app/db/migrations/versions/env.py, resources/types/api.ts, resources/pages/access/components/user-login-form.tsx, resources/pages/access/components/user-registration-form.tsx
17 | sonar.projectName=Litestar Fullstack
18 |
--------------------------------------------------------------------------------
/src/app/__about__.py:
--------------------------------------------------------------------------------
1 | # SPDX-FileCopyrightText: 2023-present Cody Fincher
2 | #
3 | # SPDX-License-Identifier: MIT
4 | __version__ = "0.2.0"
5 |
--------------------------------------------------------------------------------
/src/app/__init__.py:
--------------------------------------------------------------------------------
1 | # SPDX-FileCopyrightText: 2023-present Cody Fincher
2 | #
3 | # SPDX-License-Identifier: MIT
4 | import multiprocessing
5 | import platform
6 |
7 | if platform.system() == "Darwin":
8 | multiprocessing.set_start_method("fork", force=True)
9 |
--------------------------------------------------------------------------------
/src/app/__main__.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | import os
4 | import sys
5 | from pathlib import Path
6 | from typing import NoReturn
7 |
8 |
9 | def setup_environment() -> None:
10 | """Configure the environment variables and path."""
11 | current_path = Path(__file__).parent.parent.resolve()
12 | sys.path.append(str(current_path))
13 | from app.config import get_settings
14 |
15 | settings = get_settings()
16 | os.environ.setdefault("LITESTAR_APP", "app.asgi:create_app")
17 | os.environ.setdefault("LITESTAR_APP_NAME", settings.app.NAME)
18 |
19 |
20 | def run_cli() -> NoReturn:
21 | """Application Entrypoint.
22 |
23 | This function sets up the environment and runs the Litestar CLI.
24 | If there's an error loading the required libraries, it will exit with a status code of 1.
25 |
26 | Returns:
27 | NoReturn: This function does not return as it either runs the CLI or exits the program.
28 |
29 | Raises:
30 | SystemExit: If there's an error loading required libraries.
31 | """
32 | setup_environment()
33 |
34 | try:
35 | from litestar.cli.main import litestar_group
36 |
37 | sys.exit(litestar_group())
38 | except ImportError as exc:
39 | print( # noqa: T201
40 | "Could not load required libraries. ",
41 | "Please check your installation and make sure you activated any necessary virtual environment",
42 | )
43 | print(exc) # noqa: T201
44 | sys.exit(1)
45 |
46 |
47 | if __name__ == "__main__":
48 | run_cli()
49 |
--------------------------------------------------------------------------------
/src/app/asgi.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | from typing import TYPE_CHECKING
4 |
5 | if TYPE_CHECKING:
6 | from litestar import Litestar
7 |
8 |
9 | def create_app() -> Litestar:
10 | """Create ASGI application."""
11 |
12 | from litestar import Litestar
13 |
14 | from app.server.core import ApplicationCore
15 |
16 | return Litestar(plugins=[ApplicationCore()])
17 |
--------------------------------------------------------------------------------
/src/app/cli/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/litestar-org/litestar-fullstack/0996536189cf48c0b7ce8c2c2600cb439968a377/src/app/cli/__init__.py
--------------------------------------------------------------------------------
/src/app/config/__init__.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | from . import app as plugin_configs
4 | from . import constants
5 | from .base import BASE_DIR, DEFAULT_MODULE_NAME, Settings, get_settings
6 |
7 | __all__ = (
8 | "BASE_DIR",
9 | "DEFAULT_MODULE_NAME",
10 | "Settings",
11 | "constants",
12 | "get_settings",
13 | "plugin_configs",
14 | )
15 |
--------------------------------------------------------------------------------
/src/app/config/constants.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | DB_SESSION_DEPENDENCY_KEY = "db_session"
4 | """The name of the key used for dependency injection of the database
5 | session."""
6 | USER_DEPENDENCY_KEY = "current_user"
7 | """The name of the key used for dependency injection of the database
8 | session."""
9 | DTO_INFO_KEY = "info"
10 | """The name of the key used for storing DTO information."""
11 | DEFAULT_PAGINATION_SIZE = 20
12 | """Default page size to use."""
13 | CACHE_EXPIRATION: int = 60
14 | """Default cache key expiration in seconds."""
15 | DEFAULT_USER_ROLE = "Application Access"
16 | """The name of the default role assigned to all users."""
17 | HEALTH_ENDPOINT = "/health"
18 | """The endpoint to use for the the service health check."""
19 | SITE_INDEX = "/"
20 | """The site index URL."""
21 | OPENAPI_SCHEMA = "/schema"
22 | """The URL path to use for the OpenAPI documentation."""
23 | SUPERUSER_ACCESS_ROLE = "Superuser"
24 | """The name of the super user role."""
25 |
--------------------------------------------------------------------------------
/src/app/db/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/litestar-org/litestar-fullstack/0996536189cf48c0b7ce8c2c2600cb439968a377/src/app/db/__init__.py
--------------------------------------------------------------------------------
/src/app/db/fixtures/role.json:
--------------------------------------------------------------------------------
1 | [
2 | {
3 | "slug": "application-access",
4 | "name": "Application Access",
5 | "description": "Default role required for access. This role allows you to query and access the application."
6 | },
7 | {
8 | "slug": "superuser",
9 | "name": "Superuser",
10 | "description": "Allows superuser access to the application."
11 | }
12 | ]
13 |
--------------------------------------------------------------------------------
/src/app/db/migrations/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/litestar-org/litestar-fullstack/0996536189cf48c0b7ce8c2c2600cb439968a377/src/app/db/migrations/__init__.py
--------------------------------------------------------------------------------
/src/app/db/migrations/alembic.ini:
--------------------------------------------------------------------------------
1 | # Advanced Alchemy Alembic Asyncio Config
2 |
3 | [alembic]
4 | prepend_sys_path = src:.
5 | # path to migration scripts
6 | script_location = src/app/lib/db/migrations
7 |
8 | # template used to generate migration files
9 | file_template = %%(year)d-%%(month).2d-%%(day).2d_%%(slug)s_%%(rev)s
10 |
11 | # This is not required to be set when running through `advanced_alchemy`
12 | # sqlalchemy.url = driver://user:pass@localhost/dbname
13 |
14 | # timezone to use when rendering the date
15 | # within the migration file as well as the filename.
16 | # string value is passed to dateutil.tz.gettz()
17 | # leave blank for localtime
18 | timezone = UTC
19 |
20 | # max length of characters to apply to the
21 | # "slug" field
22 | truncate_slug_length = 40
23 |
24 | # set to 'true' to run the environment during
25 | # the 'revision' command, regardless of autogenerate
26 | # revision_environment = false
27 |
28 | # set to 'true' to allow .pyc and .pyo files without
29 | # a source .py file to be detected as revisions in the
30 | # versions/ directory
31 | # sourceless = false
32 |
33 | # version location specification; this defaults
34 | # to alembic/versions. When using multiple version
35 | # directories, initial revisions must be specified with --version-path
36 | # version_locations = %(here)s/bar %(here)s/bat alembic/versions
37 |
38 | # version path separator; As mentioned above, this is the character used to split
39 | # version_locations. The default within new alembic.ini files is "os", which uses os.pathsep.
40 | # If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas.
41 | # Valid values for version_path_separator are:
42 | #
43 | # version_path_separator = :
44 | # version_path_separator = ;
45 | # version_path_separator = space
46 | version_path_separator = os # Use os.pathsep. Default configuration used for new projects.
47 |
48 | # set to 'true' to search source files recursively
49 | # in each "version_locations" directory
50 | # new in Alembic version 1.10
51 | # recursive_version_locations = false
52 |
53 | # the output encoding used when revision files
54 | # are written from script.py.mako
55 | output_encoding = utf-8
56 |
57 | # [post_write_hooks]
58 | # This section defines scripts or Python functions that are run
59 | # on newly generated revision scripts. See the documentation for further
60 | # detail and examples
61 |
62 | # format using "black" - use the console_scripts runner,
63 | # against the "black" entrypoint
64 | # hooks = black
65 | # black.type = console_scripts
66 | # black.entrypoint = black
67 | # black.options = -l 120 REVISION_SCRIPT_FILENAME
68 |
69 | # lint with attempts to fix using "ruff" - use the exec runner, execute a binary
70 | # hooks = ruff
71 | # ruff.type = exec
72 | # ruff.executable = %(here)s/.venv/bin/ruff
73 | # ruff.options = --fix REVISION_SCRIPT_FILENAME
74 |
--------------------------------------------------------------------------------
/src/app/db/migrations/script.py.mako:
--------------------------------------------------------------------------------
1 | # type: ignore
2 | """${message}
3 |
4 | Revision ID: ${up_revision}
5 | Revises: ${down_revision | comma,n}
6 | Create Date: ${create_date}
7 |
8 | """
9 | from __future__ import annotations
10 |
11 | import warnings
12 | from typing import TYPE_CHECKING
13 |
14 | import sqlalchemy as sa
15 | from alembic import op
16 | from advanced_alchemy.types import EncryptedString, EncryptedText, GUID, ORA_JSONB, DateTimeUTC
17 | from sqlalchemy import Text # noqa: F401
18 | ${imports if imports else ""}
19 | if TYPE_CHECKING:
20 | from collections.abc import Sequence
21 |
22 | __all__ = ["downgrade", "upgrade", "schema_upgrades", "schema_downgrades", "data_upgrades", "data_downgrades"]
23 |
24 | sa.GUID = GUID
25 | sa.DateTimeUTC = DateTimeUTC
26 | sa.ORA_JSONB = ORA_JSONB
27 | sa.EncryptedString = EncryptedString
28 | sa.EncryptedText = EncryptedText
29 |
30 | # revision identifiers, used by Alembic.
31 | revision = ${repr(up_revision)}
32 | down_revision = ${repr(down_revision)}
33 | branch_labels = ${repr(branch_labels)}
34 | depends_on = ${repr(depends_on)}
35 |
36 |
37 | def upgrade() -> None:
38 | with warnings.catch_warnings():
39 | warnings.filterwarnings("ignore", category=UserWarning)
40 | with op.get_context().autocommit_block():
41 | schema_upgrades()
42 | data_upgrades()
43 |
44 | def downgrade() -> None:
45 | with warnings.catch_warnings():
46 | warnings.filterwarnings("ignore", category=UserWarning)
47 | with op.get_context().autocommit_block():
48 | data_downgrades()
49 | schema_downgrades()
50 |
51 | def schema_upgrades() -> None:
52 | """schema upgrade migrations go here."""
53 | ${upgrades if upgrades else "pass"}
54 |
55 | def schema_downgrades() -> None:
56 | """schema downgrade migrations go here."""
57 | ${downgrades if downgrades else "pass"}
58 |
59 | def data_upgrades() -> None:
60 | """Add any optional data upgrade migrations here!"""
61 |
62 | def data_downgrades() -> None:
63 | """Add any optional data downgrade migrations here!"""
64 |
--------------------------------------------------------------------------------
/src/app/db/migrations/versions/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/litestar-org/litestar-fullstack/0996536189cf48c0b7ce8c2c2600cb439968a377/src/app/db/migrations/versions/__init__.py
--------------------------------------------------------------------------------
/src/app/db/models/__init__.py:
--------------------------------------------------------------------------------
1 | from .oauth_account import UserOauthAccount
2 | from .role import Role
3 | from .tag import Tag
4 | from .team import Team
5 | from .team_invitation import TeamInvitation
6 | from .team_member import TeamMember
7 | from .team_roles import TeamRoles
8 | from .team_tag import team_tag
9 | from .user import User
10 | from .user_role import UserRole
11 |
12 | __all__ = (
13 | "Role",
14 | "Tag",
15 | "Team",
16 | "TeamInvitation",
17 | "TeamMember",
18 | "TeamRoles",
19 | "User",
20 | "UserOauthAccount",
21 | "UserRole",
22 | "team_tag",
23 | )
24 |
--------------------------------------------------------------------------------
/src/app/db/models/oauth_account.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | from typing import TYPE_CHECKING
4 | from uuid import UUID # noqa: TC003
5 |
6 | from advanced_alchemy.base import UUIDAuditBase
7 | from sqlalchemy import ForeignKey, Integer, String
8 | from sqlalchemy.ext.associationproxy import AssociationProxy, association_proxy
9 | from sqlalchemy.orm import Mapped, mapped_column, relationship
10 |
11 | if TYPE_CHECKING:
12 | from .user import User
13 |
14 |
15 | class UserOauthAccount(UUIDAuditBase):
16 | """User Oauth Account"""
17 |
18 | __tablename__ = "user_account_oauth"
19 | __table_args__ = {"comment": "Registered OAUTH2 Accounts for Users"}
20 | __pii_columns__ = {"oauth_name", "account_email", "account_id"}
21 |
22 | user_id: Mapped[UUID] = mapped_column(
23 | ForeignKey("user_account.id", ondelete="cascade"),
24 | nullable=False,
25 | )
26 | oauth_name: Mapped[str] = mapped_column(String(length=100), index=True, nullable=False)
27 | access_token: Mapped[str] = mapped_column(String(length=1024), nullable=False)
28 | expires_at: Mapped[int | None] = mapped_column(Integer, nullable=True)
29 | refresh_token: Mapped[str | None] = mapped_column(String(length=1024), nullable=True)
30 | account_id: Mapped[str] = mapped_column(String(length=320), index=True, nullable=False)
31 | account_email: Mapped[str] = mapped_column(String(length=320), nullable=False)
32 |
33 | # -----------
34 | # ORM Relationships
35 | # ------------
36 | user_name: AssociationProxy[str] = association_proxy("user", "name")
37 | user_email: AssociationProxy[str] = association_proxy("user", "email")
38 | user: Mapped[User] = relationship(
39 | back_populates="oauth_accounts",
40 | viewonly=True,
41 | innerjoin=True,
42 | lazy="joined",
43 | )
44 |
--------------------------------------------------------------------------------
/src/app/db/models/role.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | from typing import TYPE_CHECKING
4 |
5 | from advanced_alchemy.base import UUIDAuditBase
6 | from advanced_alchemy.mixins import SlugKey
7 | from sqlalchemy.orm import Mapped, mapped_column, relationship
8 |
9 | if TYPE_CHECKING:
10 | from .user_role import UserRole
11 |
12 |
13 | class Role(UUIDAuditBase, SlugKey):
14 | """Role."""
15 |
16 | __tablename__ = "role"
17 |
18 | name: Mapped[str] = mapped_column(unique=True)
19 | description: Mapped[str | None]
20 | # -----------
21 | # ORM Relationships
22 | # ------------
23 | users: Mapped[list[UserRole]] = relationship(
24 | back_populates="role",
25 | cascade="all, delete",
26 | lazy="noload",
27 | viewonly=True,
28 | )
29 |
--------------------------------------------------------------------------------
/src/app/db/models/tag.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | from typing import TYPE_CHECKING
4 |
5 | from advanced_alchemy.base import UUIDAuditBase
6 | from advanced_alchemy.mixins import SlugKey, UniqueMixin
7 | from advanced_alchemy.utils.text import slugify
8 | from sqlalchemy import (
9 | ColumnElement,
10 | String,
11 | Table,
12 | )
13 | from sqlalchemy.orm import Mapped, mapped_column, relationship
14 |
15 | if TYPE_CHECKING:
16 | from collections.abc import Hashable
17 |
18 | from .team import Team
19 |
20 |
21 | class Tag(UUIDAuditBase, SlugKey, UniqueMixin):
22 | """Tag."""
23 |
24 | __tablename__ = "tag"
25 | name: Mapped[str] = mapped_column(index=False)
26 | description: Mapped[str | None] = mapped_column(String(length=255), index=False, nullable=True)
27 |
28 | # -----------
29 | # ORM Relationships
30 | # ------------
31 | teams: Mapped[list[Team]] = relationship(
32 | secondary=lambda: _team_tag(),
33 | back_populates="tags",
34 | )
35 |
36 | @classmethod
37 | def unique_hash(cls, name: str, slug: str | None = None) -> Hashable: # noqa: ARG003
38 | return slugify(name)
39 |
40 | @classmethod
41 | def unique_filter(
42 | cls,
43 | name: str,
44 | slug: str | None = None, # noqa: ARG003
45 | ) -> ColumnElement[bool]:
46 | return cls.slug == slugify(name)
47 |
48 |
49 | def _team_tag() -> Table:
50 | from .team_tag import team_tag
51 |
52 | return team_tag
53 |
--------------------------------------------------------------------------------
/src/app/db/models/team.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | from typing import TYPE_CHECKING
4 |
5 | from advanced_alchemy.base import UUIDAuditBase
6 | from advanced_alchemy.mixins import SlugKey
7 | from sqlalchemy import String
8 | from sqlalchemy.orm import Mapped, mapped_column, relationship
9 |
10 | from .team_tag import team_tag
11 |
12 | if TYPE_CHECKING:
13 | from .tag import Tag
14 | from .team_invitation import TeamInvitation
15 | from .team_member import TeamMember
16 |
17 |
18 | class Team(UUIDAuditBase, SlugKey):
19 | """A group of users with common permissions.
20 | Users can create and invite users to a team.
21 | """
22 |
23 | __tablename__ = "team"
24 | __pii_columns__ = {"name", "description"}
25 | name: Mapped[str] = mapped_column(nullable=False, index=True)
26 | description: Mapped[str | None] = mapped_column(String(length=500), nullable=True, default=None)
27 | is_active: Mapped[bool] = mapped_column(default=True, nullable=False)
28 | # -----------
29 | # ORM Relationships
30 | # ------------
31 | members: Mapped[list[TeamMember]] = relationship(
32 | back_populates="team",
33 | cascade="all, delete",
34 | passive_deletes=True,
35 | lazy="selectin",
36 | )
37 | invitations: Mapped[list[TeamInvitation]] = relationship(
38 | back_populates="team",
39 | cascade="all, delete",
40 | )
41 | pending_invitations: Mapped[list[TeamInvitation]] = relationship(
42 | primaryjoin="and_(TeamInvitation.team_id==Team.id, TeamInvitation.is_accepted == False)",
43 | viewonly=True,
44 | )
45 | tags: Mapped[list[Tag]] = relationship(
46 | secondary=lambda: team_tag,
47 | back_populates="teams",
48 | cascade="all, delete",
49 | passive_deletes=True,
50 | )
51 |
--------------------------------------------------------------------------------
/src/app/db/models/team_invitation.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | from typing import TYPE_CHECKING
4 | from uuid import UUID # noqa: TC003
5 |
6 | from advanced_alchemy.base import UUIDAuditBase
7 | from sqlalchemy import ForeignKey, String
8 | from sqlalchemy.orm import Mapped, mapped_column, relationship
9 |
10 | from app.db.models.team_roles import TeamRoles
11 |
12 | if TYPE_CHECKING:
13 | from .team import Team
14 | from .user import User
15 |
16 |
17 | class TeamInvitation(UUIDAuditBase):
18 | """Team Invite."""
19 |
20 | __tablename__ = "team_invitation"
21 | team_id: Mapped[UUID] = mapped_column(ForeignKey("team.id", ondelete="cascade"))
22 | email: Mapped[str] = mapped_column(index=True)
23 | role: Mapped[TeamRoles] = mapped_column(String(length=50), default=TeamRoles.MEMBER)
24 | is_accepted: Mapped[bool] = mapped_column(default=False)
25 | invited_by_id: Mapped[UUID | None] = mapped_column(ForeignKey("user_account.id", ondelete="set null"))
26 | invited_by_email: Mapped[str]
27 | # -----------
28 | # ORM Relationships
29 | # ------------
30 | team: Mapped[Team] = relationship(foreign_keys="TeamInvitation.team_id", lazy="noload")
31 | invited_by: Mapped[User] = relationship(foreign_keys="TeamInvitation.invited_by_id", lazy="noload", uselist=False)
32 |
--------------------------------------------------------------------------------
/src/app/db/models/team_member.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | from typing import TYPE_CHECKING
4 | from uuid import UUID # noqa: TC003
5 |
6 | from advanced_alchemy.base import UUIDAuditBase
7 | from sqlalchemy import ForeignKey, String, UniqueConstraint
8 | from sqlalchemy.ext.associationproxy import AssociationProxy, association_proxy
9 | from sqlalchemy.orm import Mapped, mapped_column, relationship
10 |
11 | from .team_roles import TeamRoles
12 |
13 | if TYPE_CHECKING:
14 | from .team import Team
15 | from .user import User
16 |
17 |
18 | class TeamMember(UUIDAuditBase):
19 | """Team Membership."""
20 |
21 | __tablename__ = "team_member"
22 | __table_args__ = (UniqueConstraint("user_id", "team_id"),)
23 | user_id: Mapped[UUID] = mapped_column(ForeignKey("user_account.id", ondelete="cascade"), nullable=False)
24 | team_id: Mapped[UUID] = mapped_column(ForeignKey("team.id", ondelete="cascade"), nullable=False)
25 | role: Mapped[TeamRoles] = mapped_column(
26 | String(length=50),
27 | default=TeamRoles.MEMBER,
28 | nullable=False,
29 | index=True,
30 | )
31 | is_owner: Mapped[bool] = mapped_column(default=False, nullable=False)
32 |
33 | # -----------
34 | # ORM Relationships
35 | # ------------
36 | user: Mapped[User] = relationship(
37 | back_populates="teams",
38 | foreign_keys="TeamMember.user_id",
39 | innerjoin=True,
40 | uselist=False,
41 | lazy="joined",
42 | )
43 | name: AssociationProxy[str] = association_proxy("user", "name")
44 | email: AssociationProxy[str] = association_proxy("user", "email")
45 | team: Mapped[Team] = relationship(
46 | back_populates="members",
47 | foreign_keys="TeamMember.team_id",
48 | innerjoin=True,
49 | uselist=False,
50 | lazy="joined",
51 | )
52 | team_name: AssociationProxy[str] = association_proxy("team", "name")
53 |
--------------------------------------------------------------------------------
/src/app/db/models/team_roles.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | from enum import Enum
4 |
5 |
6 | class TeamRoles(str, Enum):
7 | """Valid Values for Team Roles."""
8 |
9 | ADMIN = "ADMIN"
10 | MEMBER = "MEMBER"
11 |
--------------------------------------------------------------------------------
/src/app/db/models/team_tag.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | from advanced_alchemy.base import orm_registry
4 | from sqlalchemy import Column, ForeignKey, Table
5 |
6 | team_tag = Table(
7 | "team_tag",
8 | orm_registry.metadata,
9 | Column("team_id", ForeignKey("team.id", ondelete="CASCADE"), primary_key=True),
10 | Column("tag_id", ForeignKey("tag.id", ondelete="CASCADE"), primary_key=True),
11 | )
12 |
--------------------------------------------------------------------------------
/src/app/db/models/user.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | from datetime import date, datetime
4 | from typing import TYPE_CHECKING
5 |
6 | from advanced_alchemy.base import UUIDAuditBase
7 | from sqlalchemy import String
8 | from sqlalchemy.ext.hybrid import hybrid_property
9 | from sqlalchemy.orm import Mapped, mapped_column, relationship
10 |
11 | if TYPE_CHECKING:
12 | from .oauth_account import UserOauthAccount
13 | from .team_member import TeamMember
14 | from .user_role import UserRole
15 |
16 |
17 | class User(UUIDAuditBase):
18 | __tablename__ = "user_account"
19 | __table_args__ = {"comment": "User accounts for application access"}
20 | __pii_columns__ = {"name", "email", "avatar_url"}
21 |
22 | email: Mapped[str] = mapped_column(unique=True, index=True, nullable=False)
23 | name: Mapped[str | None] = mapped_column(nullable=True, default=None)
24 | hashed_password: Mapped[str | None] = mapped_column(String(length=255), nullable=True, default=None)
25 | avatar_url: Mapped[str | None] = mapped_column(String(length=500), nullable=True, default=None)
26 | is_active: Mapped[bool] = mapped_column(default=True, nullable=False)
27 | is_superuser: Mapped[bool] = mapped_column(default=False, nullable=False)
28 | is_verified: Mapped[bool] = mapped_column(default=False, nullable=False)
29 | verified_at: Mapped[date] = mapped_column(nullable=True, default=None)
30 | joined_at: Mapped[date] = mapped_column(default=datetime.now)
31 | login_count: Mapped[int] = mapped_column(default=0)
32 | # -----------
33 | # ORM Relationships
34 | # ------------
35 |
36 | roles: Mapped[list[UserRole]] = relationship(
37 | back_populates="user",
38 | lazy="selectin",
39 | uselist=True,
40 | cascade="all, delete",
41 | )
42 | teams: Mapped[list[TeamMember]] = relationship(
43 | back_populates="user",
44 | lazy="selectin",
45 | uselist=True,
46 | cascade="all, delete",
47 | viewonly=True,
48 | )
49 | oauth_accounts: Mapped[list[UserOauthAccount]] = relationship(
50 | back_populates="user",
51 | lazy="noload",
52 | cascade="all, delete",
53 | uselist=True,
54 | )
55 |
56 | @hybrid_property
57 | def has_password(self) -> bool:
58 | return self.hashed_password is not None
59 |
--------------------------------------------------------------------------------
/src/app/db/models/user_role.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | from datetime import UTC, datetime
4 | from typing import TYPE_CHECKING
5 | from uuid import UUID # noqa: TC003
6 |
7 | from advanced_alchemy.base import UUIDAuditBase
8 | from sqlalchemy import ForeignKey
9 | from sqlalchemy.ext.associationproxy import AssociationProxy, association_proxy
10 | from sqlalchemy.orm import Mapped, mapped_column, relationship
11 |
12 | if TYPE_CHECKING:
13 | from .role import Role
14 | from .user import User
15 |
16 |
17 | class UserRole(UUIDAuditBase):
18 | """User Role."""
19 |
20 | __tablename__ = "user_account_role"
21 | __table_args__ = {"comment": "Links a user to a specific role."}
22 | user_id: Mapped[UUID] = mapped_column(ForeignKey("user_account.id", ondelete="cascade"), nullable=False)
23 | role_id: Mapped[UUID] = mapped_column(ForeignKey("role.id", ondelete="cascade"), nullable=False)
24 | assigned_at: Mapped[datetime] = mapped_column(default=datetime.now(UTC))
25 |
26 | # -----------
27 | # ORM Relationships
28 | # ------------
29 | user: Mapped[User] = relationship(back_populates="roles", innerjoin=True, uselist=False, lazy="joined")
30 | user_name: AssociationProxy[str] = association_proxy("user", "name")
31 | user_email: AssociationProxy[str] = association_proxy("user", "email")
32 | role: Mapped[Role] = relationship(back_populates="users", innerjoin=True, uselist=False, lazy="joined")
33 | role_name: AssociationProxy[str] = association_proxy("role", "name")
34 | role_slug: AssociationProxy[str] = association_proxy("role", "slug")
35 |
--------------------------------------------------------------------------------
/src/app/domain/__init__.py:
--------------------------------------------------------------------------------
1 | """Application Modules."""
2 |
3 | from __future__ import annotations
4 |
--------------------------------------------------------------------------------
/src/app/domain/accounts/__init__.py:
--------------------------------------------------------------------------------
1 | """User Account domain logic."""
2 |
3 | from app.domain.accounts import controllers, deps, guards, schemas, services, signals, urls
4 |
5 | __all__ = ("controllers", "deps", "guards", "schemas", "services", "signals", "urls")
6 |
--------------------------------------------------------------------------------
/src/app/domain/accounts/controllers/__init__.py:
--------------------------------------------------------------------------------
1 | from .access import AccessController
2 | from .roles import RoleController
3 | from .user_role import UserRoleController
4 | from .users import UserController
5 |
6 | __all__ = ("AccessController", "RoleController", "UserController", "UserRoleController")
7 |
--------------------------------------------------------------------------------
/src/app/domain/accounts/controllers/access.py:
--------------------------------------------------------------------------------
1 | """User Account Controllers."""
2 |
3 | from __future__ import annotations
4 |
5 | from typing import TYPE_CHECKING, Annotated
6 |
7 | from advanced_alchemy.utils.text import slugify
8 | from litestar import Controller, Request, Response, get, post
9 | from litestar.di import Provide
10 | from litestar.enums import RequestEncodingType
11 | from litestar.params import Body
12 |
13 | from app.domain.accounts import urls
14 | from app.domain.accounts.deps import provide_users_service
15 | from app.domain.accounts.guards import auth, requires_active_user
16 | from app.domain.accounts.schemas import AccountLogin, AccountRegister, User
17 | from app.domain.accounts.services import RoleService
18 | from app.lib.deps import create_service_provider
19 |
20 | if TYPE_CHECKING:
21 | from litestar.security.jwt import OAuth2Login
22 |
23 | from app.db import models as m
24 | from app.domain.accounts.services import UserService
25 |
26 |
27 | class AccessController(Controller):
28 | """User login and registration."""
29 |
30 | tags = ["Access"]
31 | dependencies = {
32 | "users_service": Provide(provide_users_service),
33 | "roles_service": Provide(create_service_provider(RoleService)),
34 | }
35 |
36 | @post(operation_id="AccountLogin", path=urls.ACCOUNT_LOGIN, exclude_from_auth=True)
37 | async def login(
38 | self,
39 | users_service: UserService,
40 | data: Annotated[AccountLogin, Body(title="OAuth2 Login", media_type=RequestEncodingType.URL_ENCODED)],
41 | ) -> Response[OAuth2Login]:
42 | """Authenticate a user."""
43 | user = await users_service.authenticate(data.username, data.password)
44 | return auth.login(user.email)
45 |
46 | @post(operation_id="AccountLogout", path=urls.ACCOUNT_LOGOUT, exclude_from_auth=True)
47 | async def logout(self, request: Request) -> Response:
48 | """Account Logout"""
49 | request.cookies.pop(auth.key, None)
50 | request.clear_session()
51 |
52 | response = Response(
53 | {"message": "OK"},
54 | status_code=200,
55 | )
56 | response.delete_cookie(auth.key)
57 |
58 | return response
59 |
60 | @post(operation_id="AccountRegister", path=urls.ACCOUNT_REGISTER)
61 | async def signup(
62 | self,
63 | request: Request,
64 | users_service: UserService,
65 | roles_service: RoleService,
66 | data: AccountRegister,
67 | ) -> User:
68 | """User Signup."""
69 | user_data = data.to_dict()
70 | role_obj = await roles_service.get_one_or_none(slug=slugify(users_service.default_role))
71 | if role_obj is not None:
72 | user_data.update({"role_id": role_obj.id})
73 | user = await users_service.create(user_data)
74 | request.app.emit(event_id="user_created", user_id=user.id)
75 | return users_service.to_schema(user, schema_type=User)
76 |
77 | @get(operation_id="AccountProfile", path=urls.ACCOUNT_PROFILE, guards=[requires_active_user])
78 | async def profile(self, current_user: m.User, users_service: UserService) -> User:
79 | """User Profile."""
80 | return users_service.to_schema(current_user, schema_type=User)
81 |
--------------------------------------------------------------------------------
/src/app/domain/accounts/controllers/roles.py:
--------------------------------------------------------------------------------
1 | """Role Routes."""
2 |
3 | from __future__ import annotations
4 |
5 | from litestar import Controller
6 |
7 | from app.domain.accounts.guards import requires_superuser
8 |
9 |
10 | class RoleController(Controller):
11 | """Handles the adding and removing of new Roles."""
12 |
13 | tags = ["Roles"]
14 | guards = [requires_superuser]
15 |
--------------------------------------------------------------------------------
/src/app/domain/accounts/controllers/user_role.py:
--------------------------------------------------------------------------------
1 | """User Routes."""
2 |
3 | from __future__ import annotations
4 |
5 | from litestar import Controller, post
6 | from litestar.di import Provide
7 | from litestar.params import Parameter
8 | from litestar.repository.exceptions import ConflictError
9 |
10 | from app.domain.accounts import deps, schemas, urls
11 | from app.domain.accounts.guards import requires_superuser
12 | from app.domain.accounts.services import RoleService, UserRoleService, UserService
13 | from app.lib.deps import create_service_provider
14 | from app.lib.schema import Message
15 |
16 |
17 | class UserRoleController(Controller):
18 | """Handles the adding and removing of User Role records."""
19 |
20 | tags = ["User Account Roles"]
21 | guards = [requires_superuser]
22 | dependencies = {
23 | "user_roles_service": Provide(create_service_provider(UserRoleService)),
24 | "roles_service": Provide(create_service_provider(RoleService)),
25 | "users_service": Provide(deps.provide_users_service),
26 | }
27 |
28 | @post(operation_id="AssignUserRole", path=urls.ACCOUNT_ASSIGN_ROLE)
29 | async def assign_role(
30 | self,
31 | roles_service: RoleService,
32 | users_service: UserService,
33 | user_roles_service: UserRoleService,
34 | data: schemas.UserRoleAdd,
35 | role_slug: str = Parameter(title="Role Slug", description="The role to grant."),
36 | ) -> Message:
37 | """Create a new migration role."""
38 | role_id = (await roles_service.get_one(slug=role_slug)).id
39 | user_obj = await users_service.get_one(email=data.user_name)
40 | obj, created = await user_roles_service.get_or_upsert(role_id=role_id, user_id=user_obj.id)
41 | if created:
42 | return Message(message=f"Successfully assigned the '{obj.role_slug}' role to {obj.user_email}.")
43 | return Message(message=f"User {obj.user_email} already has the '{obj.role_slug}' role.")
44 |
45 | @post(operation_id="RevokeUserRole", path=urls.ACCOUNT_REVOKE_ROLE)
46 | async def revoke_role(
47 | self,
48 | users_service: UserService,
49 | user_roles_service: UserRoleService,
50 | data: schemas.UserRoleRevoke,
51 | role_slug: str = Parameter(title="Role Slug", description="The role to revoke."),
52 | ) -> Message:
53 | """Delete a role from the system."""
54 | user_obj = await users_service.get_one(email=data.user_name)
55 | removed_role: bool = False
56 | for user_role in user_obj.roles:
57 | if user_role.role_slug == role_slug:
58 | _ = await user_roles_service.delete(user_role.id)
59 | removed_role = True
60 | if not removed_role:
61 | msg = "User did not have role assigned."
62 | raise ConflictError(msg)
63 | return Message(message=f"Removed the '{role_slug}' role from User {user_obj.email}.")
64 |
--------------------------------------------------------------------------------
/src/app/domain/accounts/controllers/users.py:
--------------------------------------------------------------------------------
1 | """User Account Controllers."""
2 |
3 | from __future__ import annotations
4 |
5 | from typing import TYPE_CHECKING, Annotated
6 | from uuid import UUID
7 |
8 | from litestar import Controller, delete, get, patch, post
9 | from litestar.di import Provide
10 | from litestar.params import Dependency, Parameter
11 |
12 | from app.domain.accounts import urls
13 | from app.domain.accounts.deps import provide_users_service
14 | from app.domain.accounts.guards import requires_superuser
15 | from app.domain.accounts.schemas import User, UserCreate, UserUpdate
16 | from app.lib.deps import create_filter_dependencies
17 |
18 | if TYPE_CHECKING:
19 | from advanced_alchemy.filters import FilterTypes
20 | from advanced_alchemy.service import OffsetPagination
21 |
22 | from app.domain.accounts.services import UserService
23 |
24 |
25 | class UserController(Controller):
26 | """User Account Controller."""
27 |
28 | tags = ["User Accounts"]
29 | guards = [requires_superuser]
30 | dependencies = {
31 | "users_service": Provide(provide_users_service),
32 | } | create_filter_dependencies(
33 | {
34 | "id_filter": UUID,
35 | "search": "name,email",
36 | "pagination_type": "limit_offset",
37 | "pagination_size": 20,
38 | "created_at": True,
39 | "updated_at": True,
40 | "sort_field": "name",
41 | "sort_order": "asc",
42 | },
43 | )
44 |
45 | @get(operation_id="ListUsers", path=urls.ACCOUNT_LIST, cache=60)
46 | async def list_users(
47 | self,
48 | users_service: UserService,
49 | filters: Annotated[list[FilterTypes], Dependency(skip_validation=True)],
50 | ) -> OffsetPagination[User]:
51 | """List users."""
52 | results, total = await users_service.list_and_count(*filters)
53 | return users_service.to_schema(data=results, total=total, schema_type=User, filters=filters)
54 |
55 | @get(operation_id="GetUser", path=urls.ACCOUNT_DETAIL)
56 | async def get_user(
57 | self,
58 | users_service: UserService,
59 | user_id: Annotated[UUID, Parameter(title="User ID", description="The user to retrieve.")],
60 | ) -> User:
61 | """Get a user."""
62 | db_obj = await users_service.get(user_id)
63 | return users_service.to_schema(db_obj, schema_type=User)
64 |
65 | @post(operation_id="CreateUser", path=urls.ACCOUNT_CREATE)
66 | async def create_user(self, users_service: UserService, data: UserCreate) -> User:
67 | """Create a new user."""
68 | db_obj = await users_service.create(data.to_dict())
69 | return users_service.to_schema(db_obj, schema_type=User)
70 |
71 | @patch(operation_id="UpdateUser", path=urls.ACCOUNT_UPDATE)
72 | async def update_user(
73 | self,
74 | data: UserUpdate,
75 | users_service: UserService,
76 | user_id: UUID = Parameter(title="User ID", description="The user to update."),
77 | ) -> User:
78 | """Create a new user."""
79 | db_obj = await users_service.update(item_id=user_id, data=data.to_dict())
80 | return users_service.to_schema(db_obj, schema_type=User)
81 |
82 | @delete(operation_id="DeleteUser", path=urls.ACCOUNT_DELETE)
83 | async def delete_user(
84 | self,
85 | users_service: UserService,
86 | user_id: Annotated[UUID, Parameter(title="User ID", description="The user to delete.")],
87 | ) -> None:
88 | """Delete a user from the system."""
89 | _ = await users_service.delete(user_id)
90 |
--------------------------------------------------------------------------------
/src/app/domain/accounts/deps.py:
--------------------------------------------------------------------------------
1 | """User Account Controllers."""
2 |
3 | from __future__ import annotations
4 |
5 | from typing import TYPE_CHECKING, Any
6 |
7 | from sqlalchemy.orm import joinedload, load_only, selectinload
8 |
9 | from app.db import models as m
10 | from app.domain.accounts.services import UserService
11 | from app.lib.deps import create_service_provider
12 |
13 | if TYPE_CHECKING:
14 | from litestar import Request
15 |
16 | # create a hard reference to this since it's used oven
17 | provide_users_service = create_service_provider(
18 | UserService,
19 | load=[
20 | selectinload(m.User.roles).options(joinedload(m.UserRole.role, innerjoin=True)),
21 | selectinload(m.User.oauth_accounts),
22 | selectinload(m.User.teams).options(
23 | joinedload(m.TeamMember.team, innerjoin=True).options(load_only(m.Team.name)),
24 | ),
25 | ],
26 | error_messages={"duplicate_key": "This user already exists.", "integrity": "User operation failed."},
27 | )
28 |
29 |
30 | async def provide_user(request: Request[m.User, Any, Any]) -> m.User:
31 | """Get the user from the request.
32 |
33 | Args:
34 | request: current Request.
35 |
36 | Returns:
37 | User
38 | """
39 | return request.user
40 |
--------------------------------------------------------------------------------
/src/app/domain/accounts/guards.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | from typing import TYPE_CHECKING, Any
4 |
5 | from litestar.exceptions import PermissionDeniedException
6 | from litestar.security.jwt import OAuth2PasswordBearerAuth
7 |
8 | from app.config import constants
9 | from app.config.app import alchemy
10 | from app.config.base import get_settings
11 | from app.db import models as m
12 | from app.domain.accounts import urls
13 | from app.domain.accounts.deps import provide_users_service
14 |
15 | if TYPE_CHECKING:
16 | from litestar.connection import ASGIConnection
17 | from litestar.handlers.base import BaseRouteHandler
18 | from litestar.security.jwt import Token
19 |
20 |
21 | __all__ = ("auth", "current_user_from_token", "requires_active_user", "requires_superuser", "requires_verified_user")
22 |
23 |
24 | settings = get_settings()
25 |
26 |
27 | def requires_active_user(connection: ASGIConnection, _: BaseRouteHandler) -> None:
28 | """Request requires active user.
29 |
30 | Verifies the request user is active.
31 |
32 | Args:
33 | connection (ASGIConnection): HTTP Request
34 | _ (BaseRouteHandler): Route handler
35 |
36 | Raises:
37 | PermissionDeniedException: Permission denied exception
38 | """
39 | if connection.user.is_active:
40 | return
41 | msg = "Inactive account"
42 | raise PermissionDeniedException(msg)
43 |
44 |
45 | def requires_superuser(connection: ASGIConnection[m.User, Any, Any, Any], _: BaseRouteHandler) -> None:
46 | """Request requires active superuser.
47 |
48 | Args:
49 | connection (ASGIConnection): HTTP Request
50 | _ (BaseRouteHandler): Route handler
51 |
52 | Raises:
53 | PermissionDeniedException: Permission denied exception
54 |
55 | Returns:
56 | None: Returns None when successful
57 | """
58 | if connection.user.is_superuser:
59 | return
60 | raise PermissionDeniedException(detail="Insufficient privileges")
61 |
62 |
63 | def requires_verified_user(connection: ASGIConnection[m.User, Any, Any, Any], _: BaseRouteHandler) -> None:
64 | """Verify the connection user is a superuser.
65 |
66 | Args:
67 | connection (ASGIConnection): Request/Connection object.
68 | _ (BaseRouteHandler): Route handler.
69 |
70 | Raises:
71 | PermissionDeniedException: Not authorized
72 |
73 | Returns:
74 | None: Returns None when successful
75 | """
76 | if connection.user.is_verified:
77 | return
78 | raise PermissionDeniedException(detail="User account is not verified.")
79 |
80 |
81 | async def current_user_from_token(token: Token, connection: ASGIConnection[Any, Any, Any, Any]) -> m.User | None:
82 | """Lookup current user from local JWT token.
83 |
84 | Fetches the user information from the database
85 |
86 |
87 | Args:
88 | token (str): JWT Token Object
89 | connection (ASGIConnection[Any, Any, Any, Any]): ASGI connection.
90 |
91 |
92 | Returns:
93 | User: User record mapped to the JWT identifier
94 | """
95 | service = await anext(provide_users_service(alchemy.provide_session(connection.app.state, connection.scope)))
96 | user = await service.get_one_or_none(email=token.sub)
97 | return user if user and user.is_active else None
98 |
99 |
100 | auth = OAuth2PasswordBearerAuth[m.User](
101 | retrieve_user_handler=current_user_from_token,
102 | token_secret=settings.app.SECRET_KEY,
103 | token_url=urls.ACCOUNT_LOGIN,
104 | exclude=[
105 | constants.HEALTH_ENDPOINT,
106 | urls.ACCOUNT_LOGIN,
107 | urls.ACCOUNT_REGISTER,
108 | "^/schema",
109 | "^/public/",
110 | "^/saq/static/",
111 | ],
112 | )
113 |
--------------------------------------------------------------------------------
/src/app/domain/accounts/schemas.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | from datetime import datetime # noqa: TC003
4 | from uuid import UUID # noqa: TC003
5 |
6 | import msgspec
7 |
8 | from app.db.models.team_roles import TeamRoles
9 | from app.lib.schema import CamelizedBaseStruct
10 |
11 | __all__ = (
12 | "AccountLogin",
13 | "AccountRegister",
14 | "User",
15 | "UserCreate",
16 | "UserRole",
17 | "UserRoleAdd",
18 | "UserRoleRevoke",
19 | "UserTeam",
20 | "UserUpdate",
21 | )
22 |
23 |
24 | class UserTeam(CamelizedBaseStruct):
25 | """Holds team details for a user.
26 |
27 | This is nested in the User Model for 'team'
28 | """
29 |
30 | team_id: UUID
31 | team_name: str
32 | is_owner: bool = False
33 | role: TeamRoles = TeamRoles.MEMBER
34 |
35 |
36 | class UserRole(CamelizedBaseStruct):
37 | """Holds role details for a user.
38 |
39 | This is nested in the User Model for 'roles'
40 | """
41 |
42 | role_id: UUID
43 | role_slug: str
44 | role_name: str
45 | assigned_at: datetime
46 |
47 |
48 | class OauthAccount(CamelizedBaseStruct):
49 | """Holds linked Oauth details for a user."""
50 |
51 | id: UUID
52 | oauth_name: str
53 | access_token: str
54 | account_id: str
55 | account_email: str
56 | expires_at: int | None = None
57 | refresh_token: str | None = None
58 |
59 |
60 | class User(CamelizedBaseStruct):
61 | """User properties to use for a response."""
62 |
63 | id: UUID
64 | email: str
65 | name: str | None = None
66 | is_superuser: bool = False
67 | is_active: bool = False
68 | is_verified: bool = False
69 | has_password: bool = False
70 | teams: list[UserTeam] = []
71 | roles: list[UserRole] = []
72 | oauth_accounts: list[OauthAccount] = []
73 |
74 |
75 | class UserCreate(CamelizedBaseStruct):
76 | email: str
77 | password: str
78 | name: str | None = None
79 | is_superuser: bool = False
80 | is_active: bool = True
81 | is_verified: bool = False
82 |
83 |
84 | class UserUpdate(CamelizedBaseStruct, omit_defaults=True):
85 | email: str | None | msgspec.UnsetType = msgspec.UNSET
86 | password: str | None | msgspec.UnsetType = msgspec.UNSET
87 | name: str | None | msgspec.UnsetType = msgspec.UNSET
88 | is_superuser: bool | None | msgspec.UnsetType = msgspec.UNSET
89 | is_active: bool | None | msgspec.UnsetType = msgspec.UNSET
90 | is_verified: bool | None | msgspec.UnsetType = msgspec.UNSET
91 |
92 |
93 | class AccountLogin(CamelizedBaseStruct):
94 | username: str
95 | password: str
96 |
97 |
98 | class AccountRegister(CamelizedBaseStruct):
99 | email: str
100 | password: str
101 | name: str | None = None
102 |
103 |
104 | class UserRoleAdd(CamelizedBaseStruct):
105 | """User role add ."""
106 |
107 | user_name: str
108 |
109 |
110 | class UserRoleRevoke(CamelizedBaseStruct):
111 | """User role revoke ."""
112 |
113 | user_name: str
114 |
--------------------------------------------------------------------------------
/src/app/domain/accounts/signals.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | from typing import TYPE_CHECKING
4 |
5 | import structlog
6 | from litestar.events import listener
7 |
8 | from app.config.app import alchemy
9 |
10 | from .deps import provide_users_service
11 |
12 | if TYPE_CHECKING:
13 | from uuid import UUID
14 |
15 | logger = structlog.get_logger()
16 |
17 |
18 | @listener("user_created")
19 | async def user_created_event_handler(
20 | user_id: UUID,
21 | ) -> None:
22 | """Executes when a new user is created.
23 |
24 | Args:
25 | user_id: The primary key of the user that was created.
26 | """
27 | await logger.ainfo("Running post signup flow.")
28 | async with alchemy.get_session() as db_session:
29 | service = await anext(provide_users_service(db_session))
30 | obj = await service.get_one_or_none(id=user_id)
31 | if obj is None:
32 | await logger.aerror("Could not locate the specified user", id=user_id)
33 | else:
34 | await logger.ainfo("Found user", **obj.to_dict(exclude={"hashed_password"}))
35 |
--------------------------------------------------------------------------------
/src/app/domain/accounts/urls.py:
--------------------------------------------------------------------------------
1 | ACCOUNT_LOGIN = "/api/access/login"
2 | ACCOUNT_LOGOUT = "/api/access/logout"
3 | ACCOUNT_REGISTER = "/api/access/signup"
4 | ACCOUNT_PROFILE = "/api/me"
5 | ACCOUNT_LIST = "/api/users"
6 | ACCOUNT_DELETE = "/api/users/{user_id:uuid}"
7 | ACCOUNT_DETAIL = "/api/users/{user_id:uuid}"
8 | ACCOUNT_UPDATE = "/api/users/{user_id:uuid}"
9 | ACCOUNT_CREATE = "/api/users"
10 | ACCOUNT_ASSIGN_ROLE = "/api/roles/{role_slug:str}/assign"
11 | ACCOUNT_REVOKE_ROLE = "/api/roles/{role_slug:str}/revoke"
12 |
--------------------------------------------------------------------------------
/src/app/domain/system/__init__.py:
--------------------------------------------------------------------------------
1 | from . import controllers, schemas, tasks
2 |
3 | __all__ = ("controllers", "schemas", "tasks")
4 |
--------------------------------------------------------------------------------
/src/app/domain/system/controllers.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | from typing import TYPE_CHECKING, Literal, TypeVar
4 |
5 | import structlog
6 | from litestar import Controller, MediaType, Request, get
7 | from litestar.response import Response
8 | from redis import RedisError
9 | from sqlalchemy import text
10 |
11 | from app.config.base import get_settings
12 |
13 | from .schemas import SystemHealth
14 | from .urls import SYSTEM_HEALTH
15 |
16 | if TYPE_CHECKING:
17 | from litestar_saq import TaskQueues
18 | from sqlalchemy.ext.asyncio import AsyncSession
19 |
20 | logger = structlog.get_logger()
21 | OnlineOffline = TypeVar("OnlineOffline", bound=Literal["online", "offline"])
22 |
23 |
24 | class SystemController(Controller):
25 | tags = ["System"]
26 |
27 | @get(
28 | operation_id="SystemHealth",
29 | name="system:health",
30 | path=SYSTEM_HEALTH,
31 | media_type=MediaType.JSON,
32 | cache=False,
33 | tags=["System"],
34 | summary="Health Check",
35 | description="Execute a health check against backend components. Returns system information including database and cache status.",
36 | )
37 | async def check_system_health(
38 | self,
39 | request: Request,
40 | db_session: AsyncSession,
41 | task_queues: TaskQueues,
42 | ) -> Response[SystemHealth]:
43 | """Check database available and returns app config info."""
44 | settings = get_settings()
45 | try:
46 | await db_session.execute(text("select 1"))
47 | db_ping = True
48 | except ConnectionRefusedError:
49 | db_ping = False
50 |
51 | db_status = "online" if db_ping else "offline"
52 | try:
53 | cache_ping = await settings.redis.get_client().ping()
54 | except RedisError:
55 | cache_ping = False
56 | cache_status = "online" if cache_ping else "offline"
57 | healthy = cache_ping and db_ping
58 | if healthy:
59 | await logger.adebug(
60 | "System Health",
61 | database_status=db_status,
62 | cache_status=cache_status,
63 | )
64 | else:
65 | await logger.awarn(
66 | "System Health Check",
67 | database_status=db_status,
68 | cache_status=cache_status,
69 | )
70 |
71 | return Response(
72 | content=SystemHealth(database_status=db_status, cache_status=cache_status), # type: ignore
73 | status_code=200 if db_ping and cache_ping else 500,
74 | media_type=MediaType.JSON,
75 | )
76 |
--------------------------------------------------------------------------------
/src/app/domain/system/schemas.py:
--------------------------------------------------------------------------------
1 | from dataclasses import dataclass
2 | from typing import Literal
3 |
4 | from app.__about__ import __version__ as current_version
5 | from app.config.base import get_settings
6 |
7 | __all__ = ("SystemHealth",)
8 |
9 | settings = get_settings()
10 |
11 |
12 | @dataclass
13 | class SystemHealth:
14 | database_status: Literal["online", "offline"]
15 | cache_status: Literal["online", "offline"]
16 | app: str = settings.app.NAME
17 | version: str = current_version
18 |
--------------------------------------------------------------------------------
/src/app/domain/system/tasks.py:
--------------------------------------------------------------------------------
1 | import asyncio
2 |
3 | from saq.types import Context
4 | from structlog import get_logger
5 |
6 | __all__ = ["background_worker_task", "system_task", "system_upkeep"]
7 |
8 |
9 | logger = get_logger()
10 |
11 |
12 | async def system_upkeep(_: Context) -> None:
13 | await logger.ainfo("Performing system upkeep operations.")
14 | await logger.ainfo("Simulating a long running operation. Sleeping for 60 seconds.")
15 | await asyncio.sleep(60)
16 | await logger.ainfo("Simulating an even long running operation. Sleeping for 120 seconds.")
17 | await asyncio.sleep(120)
18 | await logger.ainfo("Long running process complete.")
19 | await logger.ainfo("Performing system upkeep operations.")
20 |
21 |
22 | async def background_worker_task(_: Context) -> None:
23 | await logger.ainfo("Performing background worker task.")
24 | await asyncio.sleep(20)
25 | await logger.ainfo("Performing system upkeep operations.")
26 |
27 |
28 | async def system_task(_: Context) -> None:
29 | await logger.ainfo("Performing simple system task")
30 | await asyncio.sleep(2)
31 | await logger.ainfo("System task complete.")
32 |
--------------------------------------------------------------------------------
/src/app/domain/system/urls.py:
--------------------------------------------------------------------------------
1 | SYSTEM_HEALTH: str = "/health"
2 | """Default path for the service health check endpoint."""
3 |
--------------------------------------------------------------------------------
/src/app/domain/tags/__init__.py:
--------------------------------------------------------------------------------
1 | from . import controllers, services, urls
2 |
3 | __all__ = ["controllers", "services", "urls"]
4 |
--------------------------------------------------------------------------------
/src/app/domain/tags/services.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | from advanced_alchemy.repository import SQLAlchemyAsyncRepository
4 | from advanced_alchemy.service import SQLAlchemyAsyncRepositoryService
5 |
6 | from app.db import models as m
7 |
8 | __all__ = ("TagService",)
9 |
10 |
11 | class TagService(SQLAlchemyAsyncRepositoryService[m.Tag]):
12 | """Handles basic lookup operations for an Tag."""
13 |
14 | class Repository(SQLAlchemyAsyncRepository[m.Tag]):
15 | """Tag Repository."""
16 |
17 | model_type = m.Tag
18 |
19 | repository_type = Repository
20 | match_fields = ["name"]
21 |
--------------------------------------------------------------------------------
/src/app/domain/tags/urls.py:
--------------------------------------------------------------------------------
1 | TAG_LIST = "/api/tags"
2 | TAG_CREATE = "/api/tags"
3 | TAG_UPDATE = "/api/tags/{tag_id:uuid}"
4 | TAG_DELETE = "/api/tags/{tag_id:uuid}"
5 | TAG_DETAILS = "/api/tags/{tag_id:uuid}"
6 |
--------------------------------------------------------------------------------
/src/app/domain/teams/__init__.py:
--------------------------------------------------------------------------------
1 | """Team Application Module."""
2 |
3 | from . import controllers, guards, schemas, services, signals, urls
4 |
5 | __all__ = ("controllers", "guards", "schemas", "services", "signals", "urls")
6 |
--------------------------------------------------------------------------------
/src/app/domain/teams/controllers/__init__.py:
--------------------------------------------------------------------------------
1 | from .team_invitation import TeamInvitationController
2 | from .team_member import TeamMemberController
3 | from .teams import TeamController
4 |
5 | __all__ = ["TeamController", "TeamInvitationController", "TeamMemberController"]
6 |
--------------------------------------------------------------------------------
/src/app/domain/teams/controllers/team_invitation.py:
--------------------------------------------------------------------------------
1 | """User Account Controllers."""
2 |
3 | from __future__ import annotations
4 |
5 | from litestar import Controller
6 |
7 | from app.domain.teams.services import TeamInvitationService
8 | from app.lib.deps import create_service_provider
9 |
10 |
11 | class TeamInvitationController(Controller):
12 | """Team Invitations."""
13 |
14 | tags = ["Teams"]
15 | dependencies = {"team_invitations_service": create_service_provider(TeamInvitationService)}
16 |
--------------------------------------------------------------------------------
/src/app/domain/teams/controllers/team_member.py:
--------------------------------------------------------------------------------
1 | """User Account Controllers."""
2 |
3 | from __future__ import annotations
4 |
5 | from typing import TYPE_CHECKING
6 |
7 | from advanced_alchemy.exceptions import IntegrityError
8 | from litestar import Controller, post
9 | from litestar.di import Provide
10 | from litestar.params import Parameter
11 | from sqlalchemy.orm import contains_eager, selectinload
12 |
13 | from app.db import models as m
14 | from app.domain.accounts.deps import provide_users_service
15 | from app.domain.teams import urls
16 | from app.domain.teams.schemas import Team, TeamMemberModify
17 | from app.domain.teams.services import TeamMemberService, TeamService
18 | from app.lib.deps import create_service_provider
19 |
20 | if TYPE_CHECKING:
21 | from uuid import UUID
22 |
23 | from app.domain.accounts.services import UserService
24 |
25 |
26 | class TeamMemberController(Controller):
27 | """Team Members."""
28 |
29 | tags = ["Team Members"]
30 | dependencies = {
31 | "teams_service": create_service_provider(TeamService, load=[m.Team.tags, m.Team.members]),
32 | "team_members_service": create_service_provider(
33 | TeamMemberService,
34 | load=[
35 | selectinload(m.TeamMember.team).options(contains_eager(m.Team.tags)),
36 | selectinload(m.TeamMember.user),
37 | ],
38 | ),
39 | "users_service": Provide(provide_users_service),
40 | }
41 |
42 | @post(operation_id="AddMemberToTeam", path=urls.TEAM_ADD_MEMBER)
43 | async def add_member_to_team(
44 | self,
45 | teams_service: TeamService,
46 | users_service: UserService,
47 | data: TeamMemberModify,
48 | team_id: UUID = Parameter(title="Team ID", description="The team to update."),
49 | ) -> Team:
50 | """Add a member to a team."""
51 | team_obj = await teams_service.get(team_id)
52 | user_obj = await users_service.get_one(email=data.user_name)
53 | is_member = any(membership.team.id == team_id for membership in user_obj.teams)
54 | if is_member:
55 | msg = "User is already a member of the team."
56 | raise IntegrityError(msg)
57 | team_obj.members.append(m.TeamMember(user_id=user_obj.id, role=m.TeamRoles.MEMBER))
58 | team_obj = await teams_service.update(item_id=team_id, data=team_obj)
59 | return teams_service.to_schema(schema_type=Team, data=team_obj)
60 |
61 | @post(operation_id="RemoveMemberFromTeam", path=urls.TEAM_REMOVE_MEMBER)
62 | async def remove_member_from_team(
63 | self,
64 | teams_service: TeamService,
65 | team_members_service: TeamMemberService,
66 | users_service: UserService,
67 | data: TeamMemberModify,
68 | team_id: UUID = Parameter(title="Team ID", description="The team to delete."),
69 | ) -> Team:
70 | """Revoke a members access to a team."""
71 | user_obj = await users_service.get_one(email=data.user_name)
72 | removed_member = False
73 | for membership in user_obj.teams:
74 | if membership.user_id == user_obj.id:
75 | removed_member = True
76 | _ = await team_members_service.delete(membership.id)
77 | if not removed_member:
78 | msg = "User is not a member of this team."
79 | raise IntegrityError(msg)
80 | team_obj = await teams_service.get(team_id)
81 | return teams_service.to_schema(schema_type=Team, data=team_obj)
82 |
--------------------------------------------------------------------------------
/src/app/domain/teams/guards.py:
--------------------------------------------------------------------------------
1 | from uuid import UUID
2 |
3 | from litestar.connection import ASGIConnection
4 | from litestar.exceptions import PermissionDeniedException
5 | from litestar.handlers.base import BaseRouteHandler
6 |
7 | from app.config import constants
8 | from app.db.models import TeamRoles
9 |
10 | __all__ = ["requires_team_admin", "requires_team_membership", "requires_team_ownership"]
11 |
12 |
13 | def requires_team_membership(connection: ASGIConnection, _: BaseRouteHandler) -> None:
14 | """Verify the connection user is a member of the team.
15 |
16 | Args:
17 | connection (ASGIConnection): _description_
18 | _ (BaseRouteHandler): _description_
19 |
20 | Raises:
21 | PermissionDeniedException: _description_
22 | """
23 | team_id = connection.path_params["team_id"]
24 | has_system_role = any(
25 | assigned_role.role_name
26 | for assigned_role in connection.user.roles
27 | if assigned_role.role.name in {constants.SUPERUSER_ACCESS_ROLE}
28 | )
29 | has_team_role = any(membership.team.id == team_id for membership in connection.user.teams)
30 | if connection.user.is_superuser or has_system_role or has_team_role:
31 | return
32 | raise PermissionDeniedException(detail="Insufficient permissions to access team.")
33 |
34 |
35 | def requires_team_admin(connection: ASGIConnection, _: BaseRouteHandler) -> None:
36 | """Verify the connection user is a team admin.
37 |
38 | Args:
39 | connection (ASGIConnection): _description_
40 | _ (BaseRouteHandler): _description_
41 |
42 | Raises:
43 | PermissionDeniedException: _description_
44 | """
45 | team_id = connection.path_params["team_id"]
46 | has_system_role = any(
47 | assigned_role.role_name
48 | for assigned_role in connection.user.roles
49 | if assigned_role.role.name in {constants.SUPERUSER_ACCESS_ROLE}
50 | )
51 | has_team_role = any(
52 | membership.team.id == team_id and membership.role == TeamRoles.ADMIN for membership in connection.user.teams
53 | )
54 | if connection.user.is_superuser or has_system_role or has_team_role:
55 | return
56 | raise PermissionDeniedException(detail="Insufficient permissions to access team.")
57 |
58 |
59 | def requires_team_ownership(connection: ASGIConnection, _: BaseRouteHandler) -> None:
60 | """Verify that the connection user is the team owner.
61 |
62 | Args:
63 | connection (ASGIConnection): _description_
64 | _ (BaseRouteHandler): _description_
65 |
66 | Raises:
67 | PermissionDeniedException: _description_
68 | """
69 | team_id = UUID(connection.path_params["team_id"])
70 | has_system_role = any(
71 | assigned_role.role.name
72 | for assigned_role in connection.user.roles
73 | if assigned_role.role.name in {constants.SUPERUSER_ACCESS_ROLE}
74 | )
75 | has_team_role = any(membership.team.id == team_id and membership.is_owner for membership in connection.user.teams)
76 | if connection.user.is_superuser or has_system_role or has_team_role:
77 | return
78 |
79 | msg = "Insufficient permissions to access team."
80 | raise PermissionDeniedException(detail=msg)
81 |
--------------------------------------------------------------------------------
/src/app/domain/teams/schemas.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | from uuid import UUID # noqa: TC003
4 |
5 | import msgspec
6 |
7 | from app.db.models.team_roles import TeamRoles
8 | from app.lib.schema import CamelizedBaseStruct
9 |
10 |
11 | class TeamTag(CamelizedBaseStruct):
12 | id: UUID
13 | slug: str
14 | name: str
15 |
16 |
17 | class TeamMember(CamelizedBaseStruct):
18 | id: UUID
19 | user_id: UUID
20 | email: str
21 | name: str | None = None
22 | role: TeamRoles | None = TeamRoles.MEMBER
23 | is_owner: bool | None = False
24 |
25 |
26 | class Team(CamelizedBaseStruct):
27 | id: UUID
28 | name: str
29 | description: str | None = None
30 | members: list[TeamMember] = []
31 | tags: list[TeamTag] = []
32 |
33 |
34 | class TeamCreate(CamelizedBaseStruct):
35 | name: str
36 | description: str | None = None
37 | tags: list[str] = []
38 |
39 |
40 | class TeamUpdate(CamelizedBaseStruct, omit_defaults=True):
41 | name: str | None | msgspec.UnsetType = msgspec.UNSET
42 | description: str | None | msgspec.UnsetType = msgspec.UNSET
43 | tags: list[str] | None | msgspec.UnsetType = msgspec.UNSET
44 |
45 |
46 | class TeamMemberModify(CamelizedBaseStruct):
47 | """Team Member Modify."""
48 |
49 | user_name: str
50 |
--------------------------------------------------------------------------------
/src/app/domain/teams/signals.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | from typing import TYPE_CHECKING
4 |
5 | import structlog
6 | from litestar.events import listener
7 |
8 | from app.config.app import alchemy
9 | from app.domain.teams.services import TeamService
10 | from app.lib.deps import create_service_provider
11 |
12 | if TYPE_CHECKING:
13 | from uuid import UUID
14 |
15 | logger = structlog.get_logger()
16 |
17 |
18 | @listener("team_created")
19 | async def team_created_event_handler(
20 | team_id: UUID,
21 | ) -> None:
22 | """Executes when a new user is created.
23 |
24 | Args:
25 | team_id: The primary key of the team that was created.
26 | """
27 | provide_team_service = create_service_provider(TeamService)
28 | await logger.ainfo("Running post signup flow.")
29 | async with alchemy.get_session() as db_session:
30 | service = await anext(provide_team_service(db_session))
31 | obj = await service.get_one_or_none(id=team_id)
32 | if obj is None:
33 | await logger.aerror("Could not locate the specified team", id=team_id)
34 | else:
35 | await logger.ainfo("Found team", **obj.to_dict())
36 |
--------------------------------------------------------------------------------
/src/app/domain/teams/urls.py:
--------------------------------------------------------------------------------
1 | TEAM_LIST = "/api/teams"
2 | TEAM_DELETE = "/api/teams/{team_id:uuid}"
3 | TEAM_DETAIL = "/api/teams/{team_id:uuid}"
4 | TEAM_UPDATE = "/api/teams/{team_id:uuid}"
5 | TEAM_CREATE = "/api/teams"
6 | TEAM_INDEX = "/api/teams/{team_id:uuid}"
7 | TEAM_INVITATION_LIST = "/api/teams/{team_id:uuid}/invitations"
8 | TEAM_ADD_MEMBER = "/api/teams/{team_id:uuid}/members/add"
9 | TEAM_REMOVE_MEMBER = "/api/teams/{team_id:uuid}/members/remove"
10 |
--------------------------------------------------------------------------------
/src/app/domain/web/__init__.py:
--------------------------------------------------------------------------------
1 | from . import controllers, templates
2 |
3 | __all__ = ["controllers", "templates"]
4 |
--------------------------------------------------------------------------------
/src/app/domain/web/controllers.py:
--------------------------------------------------------------------------------
1 | from litestar import Controller, get
2 | from litestar.response import Template
3 | from litestar.status_codes import HTTP_200_OK
4 |
5 | from app.config import constants
6 |
7 |
8 | class WebController(Controller):
9 | """Web Controller."""
10 |
11 | include_in_schema = False
12 | opt = {"exclude_from_auth": True}
13 |
14 | @get(
15 | path=[constants.SITE_INDEX, f"{constants.SITE_INDEX}/{{path:path}}"],
16 | operation_id="WebIndex",
17 | name="frontend:index",
18 | status_code=HTTP_200_OK,
19 | )
20 | async def index(self, path: str | None = None) -> Template:
21 | """Serve site root."""
22 | return Template(template_name="site/index.html.j2")
23 |
--------------------------------------------------------------------------------
/src/app/domain/web/templates/email/.gitkeep:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/litestar-org/litestar-fullstack/0996536189cf48c0b7ce8c2c2600cb439968a377/src/app/domain/web/templates/email/.gitkeep
--------------------------------------------------------------------------------
/src/app/domain/web/templates/site/.gitkeep:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/litestar-org/litestar-fullstack/0996536189cf48c0b7ce8c2c2600cb439968a377/src/app/domain/web/templates/site/.gitkeep
--------------------------------------------------------------------------------
/src/app/domain/web/templates/site/index.html.j2:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 | {{ vite_hmr() }}
14 | {{ vite('resources/main.tsx') }}
15 |
16 |
17 |
18 |
--------------------------------------------------------------------------------
/src/app/lib/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/litestar-org/litestar-fullstack/0996536189cf48c0b7ce8c2c2600cb439968a377/src/app/lib/__init__.py
--------------------------------------------------------------------------------
/src/app/lib/crypt.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations # noqa: A005
2 |
3 | import asyncio
4 | import base64
5 |
6 | from passlib.context import CryptContext
7 |
8 | password_crypt_context = CryptContext(schemes=["argon2"], deprecated="auto")
9 |
10 |
11 | def get_encryption_key(secret: str) -> bytes:
12 | """Get Encryption Key.
13 |
14 | Args:
15 | secret (str): Secret key used for encryption
16 |
17 | Returns:
18 | bytes: a URL safe encoded version of secret
19 | """
20 | if len(secret) <= 32:
21 | secret = f"{secret:<32}"[:32]
22 | return base64.urlsafe_b64encode(secret.encode())
23 |
24 |
25 | async def get_password_hash(password: str | bytes) -> str:
26 | """Get password hash.
27 |
28 | Args:
29 | password: Plain password
30 | Returns:
31 | str: Hashed password
32 | """
33 | return await asyncio.get_running_loop().run_in_executor(None, password_crypt_context.hash, password)
34 |
35 |
36 | async def verify_password(plain_password: str | bytes, hashed_password: str) -> bool:
37 | """Verify Password.
38 |
39 | Args:
40 | plain_password (str | bytes): The string or byte password
41 | hashed_password (str): the hash of the password
42 |
43 | Returns:
44 | bool: True if password matches hash.
45 | """
46 | valid, _ = await asyncio.get_running_loop().run_in_executor(
47 | None,
48 | password_crypt_context.verify_and_update,
49 | plain_password,
50 | hashed_password,
51 | )
52 | return bool(valid)
53 |
--------------------------------------------------------------------------------
/src/app/lib/deps.py:
--------------------------------------------------------------------------------
1 | """Application dependency providers generators.
2 |
3 | This module contains functions to create dependency providers for services and filters.
4 |
5 | You should not have modify this module very often and should only be invoked under normal usage.
6 | """
7 |
8 | from __future__ import annotations
9 |
10 | from advanced_alchemy.extensions.litestar.providers import (
11 | DependencyCache,
12 | DependencyDefaults,
13 | create_filter_dependencies,
14 | create_service_dependencies,
15 | create_service_provider,
16 | dep_cache,
17 | )
18 |
19 | __all__ = (
20 | "DependencyCache",
21 | "DependencyDefaults",
22 | "create_filter_dependencies",
23 | "create_service_dependencies",
24 | "create_service_provider",
25 | "dep_cache",
26 | )
27 |
--------------------------------------------------------------------------------
/src/app/lib/dto.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | from typing import TYPE_CHECKING, Literal, TypeVar, overload
4 |
5 | from advanced_alchemy.extensions.litestar.dto import SQLAlchemyDTO, SQLAlchemyDTOConfig
6 | from litestar.dto import DataclassDTO, dto_field
7 | from litestar.dto.config import DTOConfig
8 | from litestar.types.protocols import DataclassProtocol
9 | from sqlalchemy.orm import DeclarativeBase
10 |
11 | if TYPE_CHECKING:
12 | from collections.abc import Set as AbstractSet
13 |
14 | from litestar.dto import RenameStrategy
15 |
16 | __all__ = ("DTOConfig", "DataclassDTO", "SQLAlchemyDTO", "config", "dto_field")
17 |
18 | DTOT = TypeVar("DTOT", bound=DataclassProtocol | DeclarativeBase)
19 | DTOFactoryT = TypeVar("DTOFactoryT", bound=DataclassDTO | SQLAlchemyDTO)
20 | SQLAlchemyModelT = TypeVar("SQLAlchemyModelT", bound=DeclarativeBase)
21 | DataclassModelT = TypeVar("DataclassModelT", bound=DataclassProtocol)
22 | ModelT = SQLAlchemyModelT | DataclassModelT
23 |
24 |
25 | @overload
26 | def config(
27 | backend: Literal["sqlalchemy"] = "sqlalchemy",
28 | exclude: AbstractSet[str] | None = None,
29 | rename_fields: dict[str, str] | None = None,
30 | rename_strategy: RenameStrategy | None = None,
31 | max_nested_depth: int | None = None,
32 | partial: bool | None = None,
33 | ) -> SQLAlchemyDTOConfig: ...
34 |
35 |
36 | @overload
37 | def config(
38 | backend: Literal["dataclass"] = "dataclass",
39 | exclude: AbstractSet[str] | None = None,
40 | rename_fields: dict[str, str] | None = None,
41 | rename_strategy: RenameStrategy | None = None,
42 | max_nested_depth: int | None = None,
43 | partial: bool | None = None,
44 | ) -> DTOConfig: ...
45 |
46 |
47 | def config(
48 | backend: Literal["dataclass", "sqlalchemy"] = "dataclass",
49 | exclude: AbstractSet[str] | None = None,
50 | rename_fields: dict[str, str] | None = None,
51 | rename_strategy: RenameStrategy | None = None,
52 | max_nested_depth: int | None = None,
53 | partial: bool | None = None,
54 | ) -> DTOConfig | SQLAlchemyDTOConfig:
55 | """_summary_
56 |
57 | Returns:
58 | DTOConfig: Configured DTO class
59 | """
60 | default_kwargs = {"rename_strategy": "camel", "max_nested_depth": 2}
61 | if exclude:
62 | default_kwargs["exclude"] = exclude
63 | if rename_fields:
64 | default_kwargs["rename_fields"] = rename_fields
65 | if rename_strategy:
66 | default_kwargs["rename_strategy"] = rename_strategy
67 | if max_nested_depth:
68 | default_kwargs["max_nested_depth"] = max_nested_depth
69 | if partial:
70 | default_kwargs["partial"] = partial
71 | return DTOConfig(**default_kwargs)
72 |
--------------------------------------------------------------------------------
/src/app/lib/schema.py:
--------------------------------------------------------------------------------
1 | from typing import Any
2 |
3 | import msgspec
4 |
5 |
6 | class BaseStruct(msgspec.Struct):
7 | def to_dict(self) -> dict[str, Any]:
8 | return {f: getattr(self, f) for f in self.__struct_fields__ if getattr(self, f, None) != msgspec.UNSET}
9 |
10 |
11 | class CamelizedBaseStruct(BaseStruct, rename="camel"):
12 | """Camelized Base Struct"""
13 |
14 |
15 | class Message(CamelizedBaseStruct):
16 | message: str
17 |
--------------------------------------------------------------------------------
/src/app/py.typed:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/litestar-org/litestar-fullstack/0996536189cf48c0b7ce8c2c2600cb439968a377/src/app/py.typed
--------------------------------------------------------------------------------
/src/app/server/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/litestar-org/litestar-fullstack/0996536189cf48c0b7ce8c2c2600cb439968a377/src/app/server/__init__.py
--------------------------------------------------------------------------------
/src/app/server/plugins.py:
--------------------------------------------------------------------------------
1 | from advanced_alchemy.extensions.litestar import SQLAlchemyPlugin
2 | from litestar.plugins.problem_details import ProblemDetailsPlugin
3 | from litestar.plugins.structlog import StructlogPlugin
4 | from litestar_granian import GranianPlugin
5 | from litestar_saq import SAQPlugin
6 | from litestar_vite import VitePlugin
7 |
8 | from app.config import app as config
9 | from app.lib.oauth import OAuth2ProviderPlugin
10 |
11 | structlog = StructlogPlugin(config=config.log)
12 | vite = VitePlugin(config=config.vite)
13 | saq = SAQPlugin(config=config.saq)
14 | alchemy = SQLAlchemyPlugin(config=config.alchemy)
15 | granian = GranianPlugin()
16 | problem_details = ProblemDetailsPlugin(config=config.problem_details)
17 | oauth = OAuth2ProviderPlugin()
18 |
--------------------------------------------------------------------------------
/tailwind.config.cjs:
--------------------------------------------------------------------------------
1 | const { fontFamily } = require("tailwindcss/defaultTheme")
2 |
3 | /** @type {import('tailwindcss').Config} */
4 | module.exports = {
5 | darkMode: ["class"],
6 | content: [
7 | "src/app/domain/web/{resources,templates}/**/*.{js,jsx,ts,cjs,mjs,tsx,vue,j2,html,htm}",
8 | "{resources,templates}/**/*.{js,cjs,mjs,jsx,ts,tsx,vue,j2,html,htm}",
9 | ],
10 | theme: {
11 | container: {
12 | center: true,
13 | padding: "2rem",
14 | screens: {
15 | "2xl": "1400px",
16 | },
17 | },
18 | extend: {
19 | colors: {
20 | border: "hsl(var(--border))",
21 | input: "hsl(var(--input))",
22 | ring: "hsl(var(--ring))",
23 | background: "hsl(var(--background))",
24 | foreground: "hsl(var(--foreground))",
25 | primary: {
26 | DEFAULT: "hsl(var(--primary))",
27 | foreground: "hsl(var(--primary-foreground))",
28 | },
29 | secondary: {
30 | DEFAULT: "hsl(var(--secondary))",
31 | foreground: "hsl(var(--secondary-foreground))",
32 | },
33 | destructive: {
34 | DEFAULT: "hsl(var(--destructive))",
35 | foreground: "hsl(var(--destructive-foreground))",
36 | },
37 | muted: {
38 | DEFAULT: "hsl(var(--muted))",
39 | foreground: "hsl(var(--muted-foreground))",
40 | },
41 | accent: {
42 | DEFAULT: "hsl(var(--accent))",
43 | foreground: "hsl(var(--accent-foreground))",
44 | },
45 | popover: {
46 | DEFAULT: "hsl(var(--popover))",
47 | foreground: "hsl(var(--popover-foreground))",
48 | },
49 | card: {
50 | DEFAULT: "hsl(var(--card))",
51 | foreground: "hsl(var(--card-foreground))",
52 | },
53 | },
54 | borderRadius: {
55 | lg: `var(--radius)`,
56 | md: `calc(var(--radius) - 2px)`,
57 | sm: "calc(var(--radius) - 4px)",
58 | },
59 | keyframes: {
60 | "accordion-down": {
61 | from: { height: "0" },
62 | to: { height: "var(--radix-accordion-content-height)" },
63 | },
64 | "accordion-up": {
65 | from: { height: "var(--radix-accordion-content-height)" },
66 | to: { height: "0" },
67 | },
68 | },
69 | animation: {
70 | "accordion-down": "accordion-down 0.2s ease-out",
71 | "accordion-up": "accordion-up 0.2s ease-out",
72 | },
73 | },
74 | },
75 | plugins: [
76 | require("tailwindcss-animate"),
77 | require("@tailwindcss/forms"),
78 | require("@tailwindcss/typography"),
79 | ],
80 | }
81 |
--------------------------------------------------------------------------------
/tests/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/litestar-org/litestar-fullstack/0996536189cf48c0b7ce8c2c2600cb439968a377/tests/__init__.py
--------------------------------------------------------------------------------
/tests/conftest.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | from typing import TYPE_CHECKING
4 |
5 | import pytest
6 | from redis.asyncio import Redis
7 |
8 | from app.config import base
9 |
10 | if TYPE_CHECKING:
11 | from collections.abc import AsyncGenerator
12 |
13 | from pytest import MonkeyPatch
14 | from pytest_databases.docker.redis import RedisService
15 |
16 |
17 | pytestmark = pytest.mark.anyio
18 | pytest_plugins = [
19 | "tests.data_fixtures",
20 | "pytest_databases.docker",
21 | "pytest_databases.docker.postgres",
22 | "pytest_databases.docker.redis",
23 | ]
24 |
25 |
26 | @pytest.fixture(scope="session")
27 | def anyio_backend() -> str:
28 | return "asyncio"
29 |
30 |
31 | @pytest.fixture(autouse=True)
32 | def _patch_settings(monkeypatch: MonkeyPatch) -> None:
33 | """Path the settings."""
34 |
35 | settings = base.Settings.from_env(".env.testing")
36 |
37 | def get_settings(dotenv_filename: str = ".env.testing") -> base.Settings:
38 | return settings
39 |
40 | monkeypatch.setattr(base, "get_settings", get_settings)
41 |
42 |
43 | @pytest.fixture(name="redis", autouse=True)
44 | async def fx_redis(redis_service: RedisService) -> AsyncGenerator[Redis, None]:
45 | """Redis instance for testing.
46 |
47 | Returns:
48 | Redis client instance, function scoped.
49 | """
50 | yield Redis(host=redis_service.host, port=redis_service.port)
51 |
--------------------------------------------------------------------------------
/tests/data_fixtures.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | from typing import TYPE_CHECKING, Any
4 |
5 | import pytest
6 |
7 | if TYPE_CHECKING:
8 | from litestar import Litestar
9 | from pytest import MonkeyPatch
10 |
11 | from app.db.models import Team, User
12 |
13 | pytestmark = pytest.mark.anyio
14 |
15 |
16 | @pytest.fixture(name="app")
17 | def fx_app(pytestconfig: pytest.Config, monkeypatch: MonkeyPatch) -> Litestar:
18 | """App fixture.
19 |
20 | Returns:
21 | An application instance, configured via plugin.
22 | """
23 | from app.asgi import create_app
24 |
25 | return create_app()
26 |
27 |
28 | @pytest.fixture(name="raw_users")
29 | def fx_raw_users() -> list[User | dict[str, Any]]:
30 | """Unstructured user representations."""
31 |
32 | return [
33 | {
34 | "id": "97108ac1-ffcb-411d-8b1e-d9183399f63b",
35 | "email": "superuser@example.com",
36 | "name": "Super User",
37 | "password": "Test_Password1!",
38 | "is_superuser": True,
39 | "is_active": True,
40 | },
41 | {
42 | "id": "5ef29f3c-3560-4d15-ba6b-a2e5c721e4d2",
43 | "email": "user@example.com",
44 | "name": "Example User",
45 | "password": "Test_Password2!",
46 | "is_superuser": False,
47 | "is_active": True,
48 | },
49 | {
50 | "id": "5ef29f3c-3560-4d15-ba6b-a2e5c721e999",
51 | "email": "test@test.com",
52 | "name": "Test User",
53 | "password": "Test_Password3!",
54 | "is_superuser": False,
55 | "is_active": True,
56 | },
57 | {
58 | "id": "6ef29f3c-3560-4d15-ba6b-a2e5c721e4d3",
59 | "email": "another@example.com",
60 | "name": "The User",
61 | "password": "Test_Password3!",
62 | "is_superuser": False,
63 | "is_active": True,
64 | },
65 | {
66 | "id": "7ef29f3c-3560-4d15-ba6b-a2e5c721e4e1",
67 | "email": "inactive@example.com",
68 | "name": "Inactive User",
69 | "password": "Old_Password2!",
70 | "is_superuser": False,
71 | "is_active": False,
72 | },
73 | ]
74 |
75 |
76 | @pytest.fixture(name="raw_teams")
77 | def fx_raw_teams() -> list[Team | dict[str, Any]]:
78 | """Unstructured team representations."""
79 |
80 | return [
81 | {
82 | "id": "97108ac1-ffcb-411d-8b1e-d9183399f63b",
83 | "slug": "test-team",
84 | "name": "Test Team",
85 | "description": "This is a description for a team.",
86 | "owner_id": "5ef29f3c-3560-4d15-ba6b-a2e5c721e4d2",
87 | },
88 | {
89 | "id": "81108ac1-ffcb-411d-8b1e-d91833999999",
90 | "slug": "simple-team",
91 | "name": "Simple Team",
92 | "description": "This is a description",
93 | "owner_id": "5ef29f3c-3560-4d15-ba6b-a2e5c721e999",
94 | "tags": ["new", "another", "extra"],
95 | },
96 | {
97 | "id": "81108ac1-ffcb-411d-8b1e-d91833999998",
98 | "slug": "extra-team",
99 | "name": "Extra Team",
100 | "description": "This is a description",
101 | "owner_id": "5ef29f3c-3560-4d15-ba6b-a2e5c721e999",
102 | "tags": ["extra"],
103 | },
104 | ]
105 |
--------------------------------------------------------------------------------
/tests/helpers.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | import inspect
4 | from contextlib import AbstractAsyncContextManager, AbstractContextManager
5 | from functools import partial
6 | from typing import TYPE_CHECKING, TypeVar, cast, overload
7 |
8 | from anyio import to_thread
9 | from typing_extensions import ParamSpec
10 |
11 | if TYPE_CHECKING:
12 | from collections.abc import Awaitable, Callable
13 | from types import TracebackType
14 |
15 | T = TypeVar("T")
16 | P = ParamSpec("P")
17 |
18 |
19 | class _ContextManagerWrapper:
20 | def __init__(self, cm: AbstractContextManager[object]) -> None:
21 | self._cm = cm
22 |
23 | async def __aenter__(self) -> object:
24 | return self._cm.__enter__()
25 |
26 | async def __aexit__(
27 | self,
28 | exc_type: type[BaseException] | None,
29 | exc_val: BaseException | None,
30 | exc_tb: TracebackType | None,
31 | ) -> bool | None:
32 | return self._cm.__exit__(exc_type, exc_val, exc_tb)
33 |
34 |
35 | @overload
36 | async def maybe_async(obj: Awaitable[T]) -> T: ...
37 |
38 |
39 | @overload
40 | async def maybe_async(obj: T) -> T: ...
41 |
42 |
43 | async def maybe_async(obj: Awaitable[T] | T) -> T:
44 | return cast(T, await obj) if inspect.isawaitable(obj) else cast(T, obj) # type: ignore[redundant-cast]
45 |
46 |
47 | def maybe_async_cm(obj: AbstractContextManager[T] | AbstractAsyncContextManager[T]) -> AbstractAsyncContextManager[T]:
48 | if isinstance(obj, AbstractContextManager):
49 | return cast(AbstractAsyncContextManager[T], _ContextManagerWrapper(obj))
50 | return obj
51 |
52 |
53 | def wrap_sync(fn: Callable[P, T]) -> Callable[P, Awaitable[T]]:
54 | if inspect.iscoroutinefunction(fn):
55 | return fn
56 |
57 | async def wrapped(*args: P.args, **kwargs: P.kwargs) -> T:
58 | return await to_thread.run_sync(partial(fn, *args, **kwargs))
59 |
60 | return wrapped
61 |
--------------------------------------------------------------------------------
/tests/integration/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/litestar-org/litestar-fullstack/0996536189cf48c0b7ce8c2c2600cb439968a377/tests/integration/__init__.py
--------------------------------------------------------------------------------
/tests/integration/test_access.py:
--------------------------------------------------------------------------------
1 | import pytest
2 | from httpx import AsyncClient
3 |
4 | pytestmark = pytest.mark.anyio
5 |
6 |
7 | @pytest.mark.parametrize(
8 | ("username", "password", "expected_status_code"),
9 | (
10 | ("superuser@example1.com", "Test_Password1!", 403),
11 | ("superuser@example.com", "Test_Password1!", 201),
12 | ("user@example.com", "Test_Password1!", 403),
13 | ("user@example.com", "Test_Password2!", 201),
14 | ("inactive@example.com", "Old_Password2!", 403),
15 | ("inactive@example.com", "Old_Password3!", 403),
16 | ),
17 | )
18 | async def test_user_login(client: AsyncClient, username: str, password: str, expected_status_code: int) -> None:
19 | response = await client.post("/api/access/login", data={"username": username, "password": password})
20 | assert response.status_code == expected_status_code
21 |
22 |
23 | @pytest.mark.parametrize(
24 | ("username", "password"),
25 | (("superuser@example.com", "Test_Password1!"),),
26 | )
27 | async def test_user_logout(client: AsyncClient, username: str, password: str) -> None:
28 | response = await client.post("/api/access/login", data={"username": username, "password": password})
29 | assert response.status_code == 201
30 | cookies = dict(response.cookies)
31 |
32 | assert cookies.get("token") is not None
33 |
34 | me_response = await client.get("/api/me")
35 | assert me_response.status_code == 200
36 |
37 | response = await client.post("/api/access/logout")
38 | assert response.status_code == 200
39 |
40 | # the user can no longer access the /me route.
41 | me_response = await client.get("/api/me")
42 | assert me_response.status_code == 401
43 |
--------------------------------------------------------------------------------
/tests/integration/test_account_role.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | from typing import TYPE_CHECKING
4 |
5 | import pytest
6 |
7 | if TYPE_CHECKING:
8 | from httpx import AsyncClient
9 |
10 |
11 | pytestmark = pytest.mark.anyio
12 |
13 |
14 | async def test_superuser_role_access(
15 | client: "AsyncClient",
16 | user_token_headers: dict[str, str],
17 | superuser_token_headers: dict[str, str],
18 | ) -> None:
19 | # user should not see all teams to start
20 | response = await client.get("/api/teams", headers=user_token_headers)
21 | assert response.status_code == 200
22 | assert int(response.json()["total"]) == 1
23 |
24 | # assign the role
25 | response = await client.post(
26 | "/api/roles/superuser/assign",
27 | json={"userName": "user@example.com"},
28 | headers=superuser_token_headers,
29 | )
30 | assert response.status_code == 201
31 | assert response.json()["message"] == "Successfully assigned the 'superuser' role to user@example.com."
32 | response = await client.patch(
33 | "/api/teams/81108ac1-ffcb-411d-8b1e-d91833999999",
34 | json={"name": "TEST UPDATE"},
35 | headers=user_token_headers,
36 | )
37 | assert response.status_code == 200
38 | # retrieve
39 | response = await client.get("/api/teams/81108ac1-ffcb-411d-8b1e-d91833999999", headers=user_token_headers)
40 | assert response.status_code == 200
41 | response = await client.get("/api/teams", headers=user_token_headers)
42 | assert response.status_code == 200
43 | assert int(response.json()["total"]) == 3
44 |
45 | # superuser should see all
46 | response = await client.get("/api/teams", headers=superuser_token_headers)
47 | assert response.status_code == 200
48 | assert int(response.json()["total"]) == 3
49 | # delete
50 | # revoke role now
51 | response = await client.post(
52 | "/api/roles/superuser/revoke",
53 | json={"userName": "user@example.com"},
54 | headers=superuser_token_headers,
55 | )
56 | assert response.status_code == 201
57 | response = await client.delete("/api/teams/81108ac1-ffcb-411d-8b1e-d91833999999", headers=user_token_headers)
58 | assert response.status_code == 403
59 | response = await client.delete("/api/teams/97108ac1-ffcb-411d-8b1e-d9183399f63b", headers=user_token_headers)
60 | assert response.status_code == 204
61 |
62 | # retrieve should now fail
63 | response = await client.get("/api/teams/81108ac1-ffcb-411d-8b1e-d91833999999", headers=user_token_headers)
64 | assert response.status_code == 403
65 | # user should only see 1 now.
66 | response = await client.get("/api/teams", headers=user_token_headers)
67 | assert response.status_code == 200
68 | assert int(response.json()["total"]) == 0
69 |
--------------------------------------------------------------------------------
/tests/integration/test_accounts.py:
--------------------------------------------------------------------------------
1 | from typing import TYPE_CHECKING
2 |
3 | import pytest
4 |
5 | if TYPE_CHECKING:
6 | from httpx import AsyncClient
7 |
8 | pytestmark = pytest.mark.anyio
9 |
10 |
11 | async def test_update_user_no_auth(client: "AsyncClient") -> None:
12 | response = await client.patch("/api/users/97108ac1-ffcb-411d-8b1e-d9183399f63b", json={"name": "TEST UPDATE"})
13 | assert response.status_code == 401
14 | response = await client.post(
15 | "/api/users/",
16 | json={"name": "A User", "email": "new-user@example.com", "password": "S3cret!"},
17 | )
18 | assert response.status_code == 401
19 | response = await client.get("/api/users/97108ac1-ffcb-411d-8b1e-d9183399f63b")
20 | assert response.status_code == 401
21 | response = await client.get("/api/users")
22 | assert response.status_code == 401
23 | response = await client.delete("/api/users/97108ac1-ffcb-411d-8b1e-d9183399f63b")
24 | assert response.status_code == 401
25 |
26 |
27 | async def test_accounts_list(client: "AsyncClient", superuser_token_headers: dict[str, str]) -> None:
28 | response = await client.get("/api/users", headers=superuser_token_headers)
29 | assert response.status_code == 200
30 | assert int(response.json()["total"]) > 0
31 |
32 |
33 | async def test_accounts_get(client: "AsyncClient", superuser_token_headers: dict[str, str]) -> None:
34 | response = await client.get("/api/users/97108ac1-ffcb-411d-8b1e-d9183399f63b", headers=superuser_token_headers)
35 | assert response.status_code == 200
36 | assert response.json()["email"] == "superuser@example.com"
37 |
38 |
39 | async def test_accounts_create(client: "AsyncClient", superuser_token_headers: dict[str, str]) -> None:
40 | response = await client.post(
41 | "/api/users",
42 | json={"name": "A User", "email": "new-user@example.com", "password": "S3cret!"},
43 | headers=superuser_token_headers,
44 | )
45 | assert response.status_code == 201
46 |
47 |
48 | async def test_accounts_update(client: "AsyncClient", superuser_token_headers: dict[str, str]) -> None:
49 | response = await client.patch(
50 | "/api/users/5ef29f3c-3560-4d15-ba6b-a2e5c721e4d2",
51 | json={
52 | "name": "Name Changed",
53 | },
54 | headers=superuser_token_headers,
55 | )
56 | assert response.status_code == 200
57 | assert response.json()["name"] == "Name Changed"
58 |
59 |
60 | async def test_accounts_delete(client: "AsyncClient", superuser_token_headers: dict[str, str]) -> None:
61 | response = await client.delete(
62 | "/api/users/5ef29f3c-3560-4d15-ba6b-a2e5c721e4d2",
63 | headers=superuser_token_headers,
64 | )
65 | assert response.status_code == 204
66 | # ensure we didn't cascade delete the teams the user owned
67 | response = await client.get(
68 | "/api/teams/97108ac1-ffcb-411d-8b1e-d9183399f63b",
69 | headers=superuser_token_headers,
70 | )
71 | assert response.status_code == 200
72 |
73 |
74 | async def test_accounts_with_incorrect_role(client: "AsyncClient", user_token_headers: dict[str, str]) -> None:
75 | response = await client.patch(
76 | "/api/users/97108ac1-ffcb-411d-8b1e-d9183399f63b",
77 | json={"name": "TEST UPDATE"},
78 | headers=user_token_headers,
79 | )
80 | assert response.status_code == 403
81 | response = await client.post(
82 | "/api/users/",
83 | json={"name": "A User", "email": "new-user@example.com", "password": "S3cret!"},
84 | headers=user_token_headers,
85 | )
86 | assert response.status_code == 403
87 | response = await client.get("/api/users/97108ac1-ffcb-411d-8b1e-d9183399f63b", headers=user_token_headers)
88 | assert response.status_code == 403
89 | response = await client.get("/api/users", headers=user_token_headers)
90 | assert response.status_code == 403
91 | response = await client.delete("/api/users/97108ac1-ffcb-411d-8b1e-d9183399f63b", headers=user_token_headers)
92 | assert response.status_code == 403
93 |
--------------------------------------------------------------------------------
/tests/integration/test_health.py:
--------------------------------------------------------------------------------
1 | import pytest
2 | from httpx import AsyncClient
3 |
4 | from app.__about__ import __version__
5 |
6 | pytestmark = pytest.mark.anyio
7 |
8 |
9 | @pytest.mark.xfail(reason="Flakey connection to service sometimes causes failures.")
10 | async def test_health(client: AsyncClient, valkey_service: None) -> None:
11 | response = await client.get("/health")
12 | assert response.status_code == 200
13 |
14 | expected = {
15 | "database_status": "online",
16 | "cache_status": "online",
17 | "app": "app",
18 | "version": __version__,
19 | }
20 |
21 | assert response.json() == expected
22 |
--------------------------------------------------------------------------------
/tests/integration/test_tags.py:
--------------------------------------------------------------------------------
1 | from typing import TYPE_CHECKING
2 |
3 | import pytest
4 |
5 | if TYPE_CHECKING:
6 | from httpx import AsyncClient
7 |
8 | pytestmark = pytest.mark.anyio
9 |
10 |
11 | async def test_tags_list(client: "AsyncClient", superuser_token_headers: dict[str, str]) -> None:
12 | response = await client.get("/api/tags", headers=superuser_token_headers)
13 | resj = response.json()
14 | assert response.status_code == 200
15 | assert int(resj["total"]) == 3
16 |
--------------------------------------------------------------------------------
/tests/integration/test_tests.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | from typing import TYPE_CHECKING, cast
4 |
5 | import pytest
6 | from litestar import get
7 | from litestar.testing import AsyncTestClient
8 |
9 | if TYPE_CHECKING:
10 | from litestar import Litestar
11 | from litestar.stores.redis import RedisStore
12 | from redis.asyncio import Redis as AsyncRedis
13 | from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession
14 |
15 | pytestmark = pytest.mark.anyio
16 |
17 |
18 | @pytest.mark.anyio
19 | async def test_cache_on_app(app: "Litestar", redis: "AsyncRedis") -> None:
20 | """Test that the app's cache is patched.
21 |
22 | Args:
23 | app: The test Litestar instance
24 | redis: The test Redis client instance.
25 | """
26 | assert cast("RedisStore", app.stores.get("response_cache"))._redis is redis
27 |
28 |
29 | @pytest.mark.anyio
30 | async def test_db_session_dependency(app: "Litestar", engine: "AsyncEngine") -> None:
31 | """Test that handlers receive session attached to patched engine.
32 |
33 | Args:
34 | app: The test Litestar instance
35 | engine: The patched SQLAlchemy engine instance.
36 | """
37 |
38 | @get("/db-session-test", opt={"exclude_from_auth": True})
39 | async def db_session_dependency_patched(db_session: AsyncSession) -> dict[str, str]:
40 | return {"result": f"{db_session.bind is engine = }"}
41 |
42 | app.register(db_session_dependency_patched)
43 | # can't use test client as it always starts its own event loop
44 | async with AsyncTestClient(app) as client:
45 | response = await client.get("/db-session-test")
46 | assert response.json()["result"] == "db_session.bind is engine = True"
47 |
--------------------------------------------------------------------------------
/tests/unit/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/litestar-org/litestar-fullstack/0996536189cf48c0b7ce8c2c2600cb439968a377/tests/unit/__init__.py
--------------------------------------------------------------------------------
/tests/unit/conftest.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | from typing import TYPE_CHECKING
4 |
5 | import pytest
6 | from litestar import Litestar, get
7 | from litestar.datastructures import State
8 | from litestar.enums import ScopeType
9 | from litestar.testing import AsyncTestClient
10 |
11 | if TYPE_CHECKING:
12 | from collections.abc import AsyncGenerator
13 |
14 | from litestar.types import HTTPResponseBodyEvent, HTTPResponseStartEvent, HTTPScope
15 |
16 | pytestmark = pytest.mark.anyio
17 |
18 |
19 | @pytest.fixture(name="client")
20 | async def fx_client(app: Litestar) -> AsyncGenerator[AsyncTestClient, None]:
21 | """Test client fixture for making calls on the global app instance."""
22 | try:
23 | async with AsyncTestClient(app=app) as client:
24 | yield client
25 | except Exception: # noqa: BLE001
26 | ...
27 |
28 |
29 | @pytest.fixture()
30 | def http_response_start() -> HTTPResponseStartEvent:
31 | """ASGI message for start of response."""
32 | return {"type": "http.response.start", "status": 200, "headers": []}
33 |
34 |
35 | @pytest.fixture()
36 | def http_response_body() -> HTTPResponseBodyEvent:
37 | """ASGI message for interim, and final response body messages.
38 |
39 | Note:
40 | `more_body` is `True` for interim body messages.
41 | """
42 | return {"type": "http.response.body", "body": b"body", "more_body": False}
43 |
44 |
45 | @pytest.fixture()
46 | def state() -> State:
47 | """Litestar application state data structure."""
48 | return State()
49 |
50 |
51 | @pytest.fixture()
52 | def http_scope(app: Litestar) -> HTTPScope:
53 | """Minimal ASGI HTTP connection scope."""
54 |
55 | @get()
56 | async def handler() -> None: ...
57 |
58 | return {
59 | "headers": [],
60 | "app": app,
61 | "litestar_app": app,
62 | "asgi": {"spec_version": "whatever", "version": "3.0"},
63 | "auth": None,
64 | "client": None,
65 | "extensions": None,
66 | "http_version": "3",
67 | "path": "/wherever",
68 | "path_params": {},
69 | "query_string": b"",
70 | "raw_path": b"/wherever",
71 | "path_template": "template.j2",
72 | "root_path": "/",
73 | "route_handler": handler,
74 | "scheme": "http",
75 | "server": None,
76 | "session": {},
77 | "state": {},
78 | "user": None,
79 | "method": "GET",
80 | "type": ScopeType.HTTP,
81 | }
82 |
--------------------------------------------------------------------------------
/tests/unit/lib/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/litestar-org/litestar-fullstack/0996536189cf48c0b7ce8c2c2600cb439968a377/tests/unit/lib/__init__.py
--------------------------------------------------------------------------------
/tests/unit/lib/test_cache.py:
--------------------------------------------------------------------------------
1 | import pytest
2 | from litestar.config.response_cache import default_cache_key_builder
3 | from litestar.testing import RequestFactory
4 |
5 | from app.server.core import ApplicationCore
6 |
7 | pytestmark = pytest.mark.anyio
8 |
9 |
10 | def test_cache_key_builder(monkeypatch: "pytest.MonkeyPatch") -> None:
11 | monkeypatch.setattr(ApplicationCore, "app_slug", "the-slug")
12 | request = RequestFactory().get("/test")
13 | default_cache_key = default_cache_key_builder(request)
14 | assert ApplicationCore()._cache_key_builder(request) == f"the-slug:{default_cache_key}"
15 |
--------------------------------------------------------------------------------
/tests/unit/lib/test_crypt.py:
--------------------------------------------------------------------------------
1 | # pylint: disable=protected-access
2 | from __future__ import annotations
3 |
4 | import base64
5 |
6 | import pytest
7 |
8 | from app.lib import crypt
9 |
10 | pytestmark = pytest.mark.anyio
11 |
12 |
13 | @pytest.mark.parametrize(
14 | ("secret_key", "expected_value"),
15 | (
16 | ("test", "test "),
17 | ("test---------------------------", "test--------------------------- "),
18 | ("test----------------------------", "test----------------------------"),
19 | ("test-----------------------------", "test-----------------------------"),
20 | (
21 | "this is a really long string that exceeds the 32 character padding added.",
22 | "this is a really long string that exceeds the 32 character padding added.",
23 | ),
24 | ),
25 | )
26 | async def test_get_encryption_key(secret_key: str, expected_value: str) -> None:
27 | """Test that the encryption key is formatted correctly."""
28 | secret = crypt.get_encryption_key(secret_key)
29 | decoded = base64.urlsafe_b64decode(secret)
30 | assert expected_value == decoded.decode()
31 |
32 |
33 | async def test_get_password_hash() -> None:
34 | """Test that the encryption key is formatted correctly."""
35 | secret_str = "This is a password!" # noqa: S105
36 | secret_bytes = b"This is a password too!"
37 | secret_str_hash = await crypt.get_password_hash(secret_str)
38 | secret_bytes_hash = await crypt.get_password_hash(secret_bytes)
39 |
40 | assert secret_str_hash.startswith("$argon2")
41 | assert secret_bytes_hash.startswith("$argon2")
42 |
43 |
44 | @pytest.mark.parametrize(
45 | ("valid_password", "tested_password", "expected_result"),
46 | (("SuperS3cret123456789!!", "SuperS3cret123456789!!", True), ("SuperS3cret123456789!!", "Invalid!!", False)),
47 | )
48 | async def test_verify_password(valid_password: str, tested_password: str, expected_result: bool) -> None:
49 | """Test that the encryption key is formatted correctly."""
50 |
51 | secret_str_hash = await crypt.get_password_hash(valid_password)
52 | is_valid = await crypt.verify_password(tested_password, secret_str_hash)
53 |
54 | assert is_valid == expected_result
55 |
--------------------------------------------------------------------------------
/tests/unit/lib/test_exceptions.py:
--------------------------------------------------------------------------------
1 | from typing import TYPE_CHECKING
2 | from unittest.mock import ANY, MagicMock
3 |
4 | import pytest
5 | from litestar import Litestar, get
6 | from litestar.repository.exceptions import ConflictError, NotFoundError
7 | from litestar.status_codes import (
8 | HTTP_403_FORBIDDEN,
9 | HTTP_404_NOT_FOUND,
10 | HTTP_409_CONFLICT,
11 | HTTP_500_INTERNAL_SERVER_ERROR,
12 | )
13 | from litestar.testing import RequestFactory, create_test_client
14 |
15 | from app.lib import exceptions
16 | from app.lib.exceptions import ApplicationError
17 |
18 | if TYPE_CHECKING:
19 | from collections import abc
20 |
21 |
22 | pytestmark = pytest.mark.anyio
23 |
24 |
25 | def test_after_exception_hook_handler_called(monkeypatch: pytest.MonkeyPatch) -> None:
26 | """Tests that the handler gets added to the app and called."""
27 | logger_mock = MagicMock()
28 | monkeypatch.setattr(exceptions, "bind_contextvars", logger_mock)
29 | exc = RuntimeError()
30 |
31 | @get("/error")
32 | async def raises() -> None:
33 | raise exc
34 |
35 | with create_test_client(
36 | route_handlers=[raises],
37 | after_exception=[exceptions.after_exception_hook_handler],
38 | ) as client:
39 | resp = client.get("/error")
40 | assert resp.status_code == HTTP_500_INTERNAL_SERVER_ERROR
41 |
42 | logger_mock.assert_called_once_with(exc_info=(RuntimeError, exc, ANY))
43 |
44 |
45 | @pytest.mark.parametrize(
46 | ("exc", "status"),
47 | [
48 | (ConflictError, HTTP_409_CONFLICT),
49 | (NotFoundError, HTTP_404_NOT_FOUND),
50 | (ApplicationError, HTTP_500_INTERNAL_SERVER_ERROR),
51 | ],
52 | )
53 | def test_repository_exception_to_http_response(exc: type[ApplicationError], status: int) -> None:
54 | app = Litestar(route_handlers=[])
55 | request = RequestFactory(app=app, server="testserver").get("/wherever")
56 | response = exceptions.exception_to_http_response(request, exc())
57 | assert response.status_code == status
58 |
59 |
60 | @pytest.mark.parametrize(
61 | ("exc", "status", "debug"),
62 | [
63 | (exceptions.AuthorizationError, HTTP_403_FORBIDDEN, True),
64 | (exceptions.AuthorizationError, HTTP_403_FORBIDDEN, False),
65 | (exceptions.ApplicationError, HTTP_500_INTERNAL_SERVER_ERROR, False),
66 | ],
67 | )
68 | def test_exception_to_http_response(exc: type[exceptions.ApplicationError], status: int, debug: bool) -> None:
69 | app = Litestar(route_handlers=[], debug=debug)
70 | request = RequestFactory(app=app, server="testserver").get("/wherever")
71 | response = exceptions.exception_to_http_response(request, exc())
72 | assert response.status_code == status
73 |
74 |
75 | @pytest.mark.parametrize(
76 | ("exc", "fn", "expected_message"),
77 | [
78 | (
79 | exceptions.ApplicationError("message"),
80 | exceptions.exception_to_http_response,
81 | b"app.lib.exceptions.ApplicationError: message\n",
82 | ),
83 | ],
84 | )
85 | def test_exception_serves_debug_middleware_response(
86 | exc: Exception,
87 | fn: "abc.Callable",
88 | expected_message: bytes,
89 | ) -> None:
90 | app = Litestar(route_handlers=[], debug=True)
91 | request = RequestFactory(app=app, server="testserver").get("/wherever")
92 | response = fn(request, exc)
93 | assert response.content == expected_message.decode()
94 |
--------------------------------------------------------------------------------
/tests/unit/lib/test_schema.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/litestar-org/litestar-fullstack/0996536189cf48c0b7ce8c2c2600cb439968a377/tests/unit/lib/test_schema.py
--------------------------------------------------------------------------------
/tests/unit/lib/test_settings.py:
--------------------------------------------------------------------------------
1 | import pytest
2 |
3 | from app.config import get_settings
4 |
5 | pytestmark = pytest.mark.anyio
6 |
7 |
8 | def test_app_slug() -> None:
9 | """Test app name conversion to slug."""
10 | settings = get_settings()
11 | settings.app.NAME = "My Application!"
12 | assert settings.app.slug == "my-application"
13 |
--------------------------------------------------------------------------------
/tests/unit/test_cli.py:
--------------------------------------------------------------------------------
1 | import pytest
2 | from click.testing import CliRunner
3 |
4 |
5 | @pytest.fixture()
6 | def cli_runner() -> CliRunner:
7 | return CliRunner()
8 |
--------------------------------------------------------------------------------
/tools/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/litestar-org/litestar-fullstack/0996536189cf48c0b7ce8c2c2600cb439968a377/tools/__init__.py
--------------------------------------------------------------------------------
/tools/build_docs.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | import argparse
4 | import shutil
5 | import subprocess
6 | from contextlib import contextmanager
7 | from pathlib import Path
8 | from typing import TYPE_CHECKING
9 |
10 | if TYPE_CHECKING:
11 | from collections.abc import Generator
12 |
13 | REDIRECT_TEMPLATE = """
14 |
15 |
16 |
17 | Page Redirection
18 |
19 |
20 |
21 |
22 |
23 | You are being redirected. If this does not work, click this link
24 |
25 |
26 | """
27 |
28 | parser = argparse.ArgumentParser()
29 | parser.add_argument("output")
30 |
31 |
32 | @contextmanager
33 | def checkout(branch: str) -> Generator[None, None, None]:
34 | subprocess.run(["git", "checkout", branch], check=True) # noqa: S607
35 | yield
36 | subprocess.run(["git", "checkout", "-"], check=True) # noqa: S607
37 |
38 |
39 | def build(output_dir: str) -> None:
40 | subprocess.run(["make", "docs"], check=True) # noqa: S607
41 |
42 | output_dir = Path(output_dir) # type: ignore[assignment]
43 | output_dir.mkdir() # type: ignore[attr-defined]
44 | output_dir.joinpath(".nojekyll").touch(exist_ok=True) # type: ignore[attr-defined]
45 | output_dir.joinpath("index.html").write_text(REDIRECT_TEMPLATE.format(target="latest")) # type: ignore[attr-defined]
46 |
47 | docs_src_path = Path("docs/_build/html")
48 | shutil.copytree(docs_src_path, output_dir / "latest", dirs_exist_ok=True) # type: ignore[operator]
49 |
50 |
51 | def main() -> None:
52 | args = parser.parse_args()
53 | build(output_dir=args.output)
54 |
55 |
56 | if __name__ == "__main__":
57 | main()
58 |
--------------------------------------------------------------------------------
/tools/manage_assets.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | import argparse
4 | import logging
5 | import os
6 | import platform
7 | import subprocess
8 | import sys
9 | from importlib.util import find_spec
10 | from pathlib import Path
11 | from typing import Any
12 |
13 | NODEENV_INSTALLED = find_spec("nodeenv") is not None
14 |
15 | logger = logging.getLogger("manage_assets")
16 |
17 | PROJECT_ROOT = Path(__file__).parent.parent
18 | NODEENV = "nodeenv"
19 | DEFAULT_VENV_PATH = Path(PROJECT_ROOT / ".venv")
20 |
21 |
22 | def manage_resources(setup_kwargs: Any) -> Any:
23 | # look for this in the environment and skip this function if it exists, sometimes building here is not needed, eg. when using nixpacks
24 | no_nodeenv = os.environ.get("LITESTAR_SKIP_NODEENV_INSTALL") is not None or NODEENV_INSTALLED is False
25 | build_assets = setup_kwargs.pop("build_assets", None)
26 | install_packages = setup_kwargs.pop("install_packages", None)
27 | kwargs: dict[str, Any] = {}
28 | if no_nodeenv:
29 | logger.info("skipping nodeenv configuration")
30 | else:
31 | found_in_local_venv = Path(DEFAULT_VENV_PATH / "bin" / NODEENV).exists()
32 | nodeenv_command = f"{DEFAULT_VENV_PATH}/bin/{NODEENV}" if found_in_local_venv else NODEENV
33 | install_dir = DEFAULT_VENV_PATH if found_in_local_venv else os.environ.get("VIRTUAL_ENV", sys.prefix)
34 | logger.info("Installing Node environment to %s:", install_dir)
35 | subprocess.run([nodeenv_command, install_dir, "--force", "--quiet"], **kwargs) # noqa: PLW1510
36 |
37 | if platform.system() == "Windows":
38 | kwargs["shell"] = True
39 | if install_packages is not None:
40 | logger.info("Installing NPM packages.")
41 | subprocess.run(["npm", "install"], **kwargs) # noqa: S607, PLW1510
42 | if build_assets is not None:
43 | logger.info("Building NPM static assets.")
44 | subprocess.run(["npm", "run", "build"], **kwargs) # noqa: S607, PLW1510
45 | return setup_kwargs
46 |
47 |
48 | if __name__ == "__main__":
49 | parser = argparse.ArgumentParser("Manage Resources")
50 | parser.add_argument("--build-assets", action="store_true", help="Build assets for static hosting.", default=None)
51 | parser.add_argument("--install-packages", action="store_true", help="Install NPM packages.", default=None)
52 | args = parser.parse_args()
53 | setup_kwargs = {"build_assets": args.build_assets, "install_packages": args.install_packages}
54 | manage_resources(setup_kwargs)
55 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES2022",
4 | "useDefineForClassFields": true,
5 | "lib": ["ES2022", "DOM", "DOM.Iterable"],
6 | "module": "ESNext",
7 |
8 | /* Bundler mode */
9 | "moduleResolution": "bundler",
10 | "allowImportingTsExtensions": true,
11 | "resolveJsonModule": true,
12 | "isolatedModules": true,
13 | "noEmit": true,
14 | "jsx": "react-jsx",
15 | "skipLibCheck": true,
16 |
17 | /* Linting */
18 | "strict": true,
19 | "noUnusedLocals": true,
20 | "noUnusedParameters": true,
21 | "noFallthroughCasesInSwitch": true,
22 | "baseUrl": "resources",
23 | "paths": {
24 | "@/*": ["./*"]
25 | },
26 | "types": ["vite/client","node"]
27 | },
28 | "include": [
29 | "**/*.d.ts",
30 | "resources/**/*.tsx",
31 | "resources/**/*.jsx",
32 | "resources/**/*.js",
33 | "resources/**/*.ts",
34 | "vite.config.ts",
35 | "vite.config.js"
36 | ]
37 | }
38 |
--------------------------------------------------------------------------------
/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from "vite"
2 | import path from "path"
3 | import litestar from "litestar-vite-plugin"
4 | import react from "@vitejs/plugin-react"
5 |
6 | const ASSET_URL = process.env.ASSET_URL || "/static/"
7 | const VITE_PORT = process.env.VITE_PORT || "5173"
8 | const VITE_HOST = process.env.VITE_HOST || "localhost"
9 | export default defineConfig({
10 | base: `${ASSET_URL}`,
11 | clearScreen: false,
12 | publicDir: "public/",
13 | server: {
14 | host: "0.0.0.0",
15 | port: +`${VITE_PORT}`,
16 | cors: true,
17 | hmr: {
18 | host: `${VITE_HOST}`,
19 | },
20 | },
21 | plugins: [
22 | react(),
23 | litestar({
24 | input: ["resources/main.tsx"],
25 | assetUrl: `${ASSET_URL}`,
26 | bundleDirectory: "src/app/domain/web/public",
27 | resourceDirectory: "resources",
28 | hotFile: "src/app/domain/web/public/hot",
29 | }),
30 | ],
31 | resolve: {
32 | alias: {
33 | "@": path.resolve(__dirname, "resources"),
34 | },
35 | },
36 | })
37 |
--------------------------------------------------------------------------------