Spaces:
Sleeping
Sleeping
EL GHAFRAOUI AYOUB
commited on
Commit
·
5760448
1
Parent(s):
8fe6367
- .env +2 -0
- Dockerfile +24 -0
- alembic.ini +100 -0
- alembic/__pycache__/env.cpython-312.pyc +0 -0
- alembic/env.py +66 -0
- alembic/script.py.mako +26 -0
- alembic/versions/3ac87142c0f1_add_invoice_sequence.py +28 -0
- alembic/versions/59c6283aeb7f_add_invoice_sequence.py +30 -0
- alembic/versions/__pycache__/3ac87142c0f1_add_invoice_sequence.cpython-312.pyc +0 -0
- alembic/versions/__pycache__/59c6283aeb7f_add_invoice_sequence.cpython-312.pyc +0 -0
- alembic/versions/__pycache__/f8ee2909b4a1_add_client_type.cpython-312.pyc +0 -0
- alembic/versions/f8ee2909b4a1_add_client_type.py +30 -0
- app/__init__.py +0 -0
- app/__pycache__/__init__.cpython-312.pyc +0 -0
- app/__pycache__/main.cpython-312.pyc +0 -0
- app/db/__pycache__/database.cpython-312.pyc +0 -0
- app/db/__pycache__/models.cpython-312.pyc +0 -0
- app/db/database.py +28 -0
- app/db/models.py +42 -0
- app/main.py +52 -0
- app/models/invoice.py +10 -0
- app/routes/__init__.py +1 -0
- app/routes/__pycache__/__init__.cpython-312.pyc +0 -0
- app/routes/__pycache__/invoices.cpython-312.pyc +0 -0
- app/routes/invoice.py +47 -0
- app/routes/invoices.py +177 -0
- app/schemas/__init__.py +1 -0
- app/schemas/__pycache__/__init__.cpython-312.pyc +0 -0
- app/schemas/__pycache__/invoice.cpython-312.pyc +0 -0
- app/schemas/invoice.py +49 -0
- app/services/__pycache__/invoice_service.cpython-312.pyc +0 -0
- app/services/invoice_service.py +236 -0
- app/static/logo.png +0 -0
- app/templates/index.html +582 -0
- migrations/versions/xxxx_add_invoice_sequence.py +28 -0
- requirements.txt +12 -0
- sql_app.db +0 -0
- tests/__init__.py +0 -0
- tests/test_api.py +0 -0
- tests/test_services.py +0 -0
.env
ADDED
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
1 |
+
DATABASE_URL=sqlite+aiosqlite:///./app.db
|
2 |
+
ENVIRONMENT=production
|
Dockerfile
ADDED
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# Use Python 3.11 slim image
|
2 |
+
FROM python:3.11-slim
|
3 |
+
|
4 |
+
# Set working directory
|
5 |
+
WORKDIR /app
|
6 |
+
|
7 |
+
# Copy requirements first to leverage Docker cache
|
8 |
+
COPY requirements.txt .
|
9 |
+
|
10 |
+
# Install dependencies
|
11 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
12 |
+
|
13 |
+
# Copy the rest of the application
|
14 |
+
COPY . .
|
15 |
+
|
16 |
+
# Create a non-root user
|
17 |
+
RUN useradd -m myuser
|
18 |
+
USER myuser
|
19 |
+
|
20 |
+
# Expose port
|
21 |
+
EXPOSE 8000
|
22 |
+
|
23 |
+
# Command to run the application
|
24 |
+
CMD ["gunicorn", "app.main:app", "-w", "4", "-k", "uvicorn.workers.UvicornWorker", "-b", "0.0.0.0:8000"]
|
alembic.ini
ADDED
@@ -0,0 +1,100 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# A generic, single database configuration.
|
2 |
+
|
3 |
+
[alembic]
|
4 |
+
# path to migration scripts
|
5 |
+
script_location = alembic
|
6 |
+
|
7 |
+
# template used to generate migration files
|
8 |
+
# file_template = %%(rev)s_%%(slug)s
|
9 |
+
|
10 |
+
# sys.path path, will be prepended to sys.path if present.
|
11 |
+
# defaults to the current working directory.
|
12 |
+
prepend_sys_path = .
|
13 |
+
|
14 |
+
# timezone to use when rendering the date within the migration file
|
15 |
+
# as well as the filename.
|
16 |
+
# If specified, requires the python-dateutil library that can be
|
17 |
+
# installed by adding `alembic[tz]` to the pip requirements
|
18 |
+
# timezone =
|
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 |
+
# The path separator used here should be the separator specified by "version_path_separator" below.
|
37 |
+
# version_locations = %(here)s/bar:%(here)s/bat:alembic/versions
|
38 |
+
|
39 |
+
# version path separator; As mentioned above, this is the character used to split
|
40 |
+
# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep.
|
41 |
+
# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or colons.
|
42 |
+
# Valid values for version_path_separator are:
|
43 |
+
#
|
44 |
+
# version_path_separator = :
|
45 |
+
# version_path_separator = ;
|
46 |
+
# version_path_separator = space
|
47 |
+
version_path_separator = os # Use os.pathsep. Default configuration used for new projects.
|
48 |
+
|
49 |
+
# the output encoding used when revision files
|
50 |
+
# are written from script.py.mako
|
51 |
+
# output_encoding = utf-8
|
52 |
+
|
53 |
+
sqlalchemy.url = sqlite+aiosqlite:///./sql_app.db
|
54 |
+
|
55 |
+
|
56 |
+
[post_write_hooks]
|
57 |
+
# post_write_hooks defines scripts or Python functions that are run
|
58 |
+
# on newly generated revision scripts. See the documentation for further
|
59 |
+
# detail and examples
|
60 |
+
|
61 |
+
# format using "black" - use the console_scripts runner, against the "black" entrypoint
|
62 |
+
# hooks = black
|
63 |
+
# black.type = console_scripts
|
64 |
+
# black.entrypoint = black
|
65 |
+
# black.options = -l 79 REVISION_SCRIPT_FILENAME
|
66 |
+
|
67 |
+
# Logging configuration
|
68 |
+
[loggers]
|
69 |
+
keys = root,sqlalchemy,alembic
|
70 |
+
|
71 |
+
[handlers]
|
72 |
+
keys = console
|
73 |
+
|
74 |
+
[formatters]
|
75 |
+
keys = generic
|
76 |
+
|
77 |
+
[logger_root]
|
78 |
+
level = WARN
|
79 |
+
handlers = console
|
80 |
+
qualname =
|
81 |
+
|
82 |
+
[logger_sqlalchemy]
|
83 |
+
level = WARN
|
84 |
+
handlers =
|
85 |
+
qualname = sqlalchemy.engine
|
86 |
+
|
87 |
+
[logger_alembic]
|
88 |
+
level = INFO
|
89 |
+
handlers =
|
90 |
+
qualname = alembic
|
91 |
+
|
92 |
+
[handler_console]
|
93 |
+
class = StreamHandler
|
94 |
+
args = (sys.stderr,)
|
95 |
+
level = NOTSET
|
96 |
+
formatter = generic
|
97 |
+
|
98 |
+
[formatter_generic]
|
99 |
+
format = %(levelname)-5.5s [%(name)s] %(message)s
|
100 |
+
datefmt = %H:%M:%S
|
alembic/__pycache__/env.cpython-312.pyc
ADDED
Binary file (3.14 kB). View file
|
|
alembic/env.py
ADDED
@@ -0,0 +1,66 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import asyncio
|
2 |
+
from logging.config import fileConfig
|
3 |
+
|
4 |
+
from sqlalchemy import pool
|
5 |
+
from sqlalchemy.engine import Connection
|
6 |
+
from sqlalchemy.ext.asyncio import async_engine_from_config
|
7 |
+
|
8 |
+
from alembic import context
|
9 |
+
|
10 |
+
# this is the Alembic Config object, which provides
|
11 |
+
# access to the values within the .ini file in use.
|
12 |
+
config = context.config
|
13 |
+
|
14 |
+
# Interpret the config file for Python logging.
|
15 |
+
# This line sets up loggers basically.
|
16 |
+
if config.config_file_name is not None:
|
17 |
+
fileConfig(config.config_file_name)
|
18 |
+
|
19 |
+
# add your model's MetaData object here
|
20 |
+
# for 'autogenerate' support
|
21 |
+
from app.db.models import Base
|
22 |
+
target_metadata = Base.metadata
|
23 |
+
|
24 |
+
def run_migrations_offline() -> None:
|
25 |
+
"""Run migrations in 'offline' mode."""
|
26 |
+
url = config.get_main_option("sqlalchemy.url")
|
27 |
+
context.configure(
|
28 |
+
url=url,
|
29 |
+
target_metadata=target_metadata,
|
30 |
+
literal_binds=True,
|
31 |
+
dialect_opts={"paramstyle": "named"},
|
32 |
+
)
|
33 |
+
|
34 |
+
with context.begin_transaction():
|
35 |
+
context.run_migrations()
|
36 |
+
|
37 |
+
def do_run_migrations(connection: Connection) -> None:
|
38 |
+
context.configure(connection=connection, target_metadata=target_metadata)
|
39 |
+
|
40 |
+
with context.begin_transaction():
|
41 |
+
context.run_migrations()
|
42 |
+
|
43 |
+
async def run_async_migrations() -> None:
|
44 |
+
"""In this scenario we need to create an Engine
|
45 |
+
and associate a connection with the context."""
|
46 |
+
|
47 |
+
connectable = async_engine_from_config(
|
48 |
+
config.get_section(config.config_ini_section, {}),
|
49 |
+
prefix="sqlalchemy.",
|
50 |
+
poolclass=pool.NullPool,
|
51 |
+
)
|
52 |
+
|
53 |
+
async with connectable.connect() as connection:
|
54 |
+
await connection.run_sync(do_run_migrations)
|
55 |
+
|
56 |
+
await connectable.dispose()
|
57 |
+
|
58 |
+
def run_migrations_online() -> None:
|
59 |
+
"""Run migrations in 'online' mode."""
|
60 |
+
|
61 |
+
asyncio.run(run_async_migrations())
|
62 |
+
|
63 |
+
if context.is_offline_mode():
|
64 |
+
run_migrations_offline()
|
65 |
+
else:
|
66 |
+
run_migrations_online()
|
alembic/script.py.mako
ADDED
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
"""${message}
|
2 |
+
|
3 |
+
Revision ID: ${up_revision}
|
4 |
+
Revises: ${down_revision | comma,n}
|
5 |
+
Create Date: ${create_date}
|
6 |
+
|
7 |
+
"""
|
8 |
+
from typing import Sequence, Union
|
9 |
+
|
10 |
+
from alembic import op
|
11 |
+
import sqlalchemy as sa
|
12 |
+
${imports if imports else ""}
|
13 |
+
|
14 |
+
# revision identifiers, used by Alembic.
|
15 |
+
revision: str = ${repr(up_revision)}
|
16 |
+
down_revision: Union[str, None] = ${repr(down_revision)}
|
17 |
+
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
18 |
+
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
19 |
+
|
20 |
+
|
21 |
+
def upgrade() -> None:
|
22 |
+
${upgrades if upgrades else "pass"}
|
23 |
+
|
24 |
+
|
25 |
+
def downgrade() -> None:
|
26 |
+
${downgrades if downgrades else "pass"}
|
alembic/versions/3ac87142c0f1_add_invoice_sequence.py
ADDED
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
"""add invoice sequence and client type
|
2 |
+
|
3 |
+
Revision ID: xxxx
|
4 |
+
Revises: previous_revision_id
|
5 |
+
Create Date: 2024-xx-xx
|
6 |
+
"""
|
7 |
+
from alembic import op
|
8 |
+
import sqlalchemy as sa
|
9 |
+
|
10 |
+
def upgrade():
|
11 |
+
# Create sequence for invoice IDs
|
12 |
+
op.execute('CREATE SEQUENCE IF NOT EXISTS invoice_id_seq START 1')
|
13 |
+
|
14 |
+
# Add client_type column if not exists
|
15 |
+
op.add_column('invoices', sa.Column('client_type', sa.String(10), nullable=True))
|
16 |
+
|
17 |
+
# Make invoice_number unique
|
18 |
+
op.create_unique_constraint('uq_invoice_number', 'invoices', ['invoice_number'])
|
19 |
+
|
20 |
+
def downgrade():
|
21 |
+
# Remove unique constraint
|
22 |
+
op.drop_constraint('uq_invoice_number', 'invoices')
|
23 |
+
|
24 |
+
# Drop client_type column
|
25 |
+
op.drop_column('invoices', 'client_type')
|
26 |
+
|
27 |
+
# Drop sequence
|
28 |
+
op.execute('DROP SEQUENCE IF EXISTS invoice_id_seq')
|
alembic/versions/59c6283aeb7f_add_invoice_sequence.py
ADDED
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
"""add invoice sequence
|
2 |
+
|
3 |
+
Revision ID: 59c6283aeb7f
|
4 |
+
Revises: f8ee2909b4a1
|
5 |
+
Create Date: 2025-01-28 23:00:46.031532
|
6 |
+
|
7 |
+
"""
|
8 |
+
from typing import Sequence, Union
|
9 |
+
|
10 |
+
from alembic import op
|
11 |
+
import sqlalchemy as sa
|
12 |
+
|
13 |
+
|
14 |
+
# revision identifiers, used by Alembic.
|
15 |
+
revision: str = '59c6283aeb7f'
|
16 |
+
down_revision: Union[str, None] = 'f8ee2909b4a1'
|
17 |
+
branch_labels: Union[str, Sequence[str], None] = None
|
18 |
+
depends_on: Union[str, Sequence[str], None] = None
|
19 |
+
|
20 |
+
|
21 |
+
def upgrade() -> None:
|
22 |
+
# ### commands auto generated by Alembic - please adjust! ###
|
23 |
+
pass
|
24 |
+
# ### end Alembic commands ###
|
25 |
+
|
26 |
+
|
27 |
+
def downgrade() -> None:
|
28 |
+
# ### commands auto generated by Alembic - please adjust! ###
|
29 |
+
pass
|
30 |
+
# ### end Alembic commands ###
|
alembic/versions/__pycache__/3ac87142c0f1_add_invoice_sequence.cpython-312.pyc
ADDED
Binary file (1.39 kB). View file
|
|
alembic/versions/__pycache__/59c6283aeb7f_add_invoice_sequence.cpython-312.pyc
ADDED
Binary file (1.01 kB). View file
|
|
alembic/versions/__pycache__/f8ee2909b4a1_add_client_type.cpython-312.pyc
ADDED
Binary file (974 Bytes). View file
|
|
alembic/versions/f8ee2909b4a1_add_client_type.py
ADDED
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
"""add client type
|
2 |
+
|
3 |
+
Revision ID: f8ee2909b4a1
|
4 |
+
Revises:
|
5 |
+
Create Date: 2025-01-28 22:54:07.661020
|
6 |
+
|
7 |
+
"""
|
8 |
+
from typing import Sequence, Union
|
9 |
+
|
10 |
+
from alembic import op
|
11 |
+
import sqlalchemy as sa
|
12 |
+
|
13 |
+
|
14 |
+
# revision identifiers, used by Alembic.
|
15 |
+
revision: str = 'f8ee2909b4a1'
|
16 |
+
down_revision: Union[str, None] = None
|
17 |
+
branch_labels: Union[str, Sequence[str], None] = None
|
18 |
+
depends_on: Union[str, Sequence[str], None] = None
|
19 |
+
|
20 |
+
|
21 |
+
def upgrade() -> None:
|
22 |
+
# ### commands auto generated by Alembic - please adjust! ###
|
23 |
+
pass
|
24 |
+
# ### end Alembic commands ###
|
25 |
+
|
26 |
+
|
27 |
+
def downgrade() -> None:
|
28 |
+
# ### commands auto generated by Alembic - please adjust! ###
|
29 |
+
pass
|
30 |
+
# ### end Alembic commands ###
|
app/__init__.py
ADDED
File without changes
|
app/__pycache__/__init__.cpython-312.pyc
ADDED
Binary file (192 Bytes). View file
|
|
app/__pycache__/main.cpython-312.pyc
ADDED
Binary file (1.79 kB). View file
|
|
app/db/__pycache__/database.cpython-312.pyc
ADDED
Binary file (2.19 kB). View file
|
|
app/db/__pycache__/models.cpython-312.pyc
ADDED
Binary file (2.2 kB). View file
|
|
app/db/database.py
ADDED
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
|
2 |
+
from sqlalchemy.orm import sessionmaker
|
3 |
+
from app.db.models import Base
|
4 |
+
|
5 |
+
# Use SQLite with aiosqlite
|
6 |
+
DATABASE_URL = "sqlite+aiosqlite:///./sql_app.db"
|
7 |
+
|
8 |
+
engine = create_async_engine(DATABASE_URL, echo=True)
|
9 |
+
AsyncSessionLocal = sessionmaker(
|
10 |
+
engine, class_=AsyncSession, expire_on_commit=False
|
11 |
+
)
|
12 |
+
|
13 |
+
async def init_db():
|
14 |
+
async with engine.begin() as conn:
|
15 |
+
# Drop all tables and recreate them
|
16 |
+
await conn.run_sync(Base.metadata.drop_all)
|
17 |
+
await conn.run_sync(Base.metadata.create_all)
|
18 |
+
|
19 |
+
async def get_db() -> AsyncSession:
|
20 |
+
async with AsyncSessionLocal() as session:
|
21 |
+
try:
|
22 |
+
yield session
|
23 |
+
await session.commit()
|
24 |
+
except Exception:
|
25 |
+
await session.rollback()
|
26 |
+
raise
|
27 |
+
finally:
|
28 |
+
await session.close()
|
app/db/models.py
ADDED
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from sqlalchemy.orm import DeclarativeBase, relationship
|
2 |
+
from sqlalchemy import Column, Integer, String, Float, DateTime, ForeignKey
|
3 |
+
from sqlalchemy.ext.declarative import declarative_base
|
4 |
+
from datetime import datetime
|
5 |
+
from sqlalchemy.sql import func
|
6 |
+
|
7 |
+
Base = declarative_base()
|
8 |
+
|
9 |
+
class Invoice(Base):
|
10 |
+
__tablename__ = "invoices"
|
11 |
+
|
12 |
+
id = Column(Integer, primary_key=True, index=True)
|
13 |
+
invoice_number = Column(String)
|
14 |
+
date = Column(DateTime, default=func.now())
|
15 |
+
project = Column(String)
|
16 |
+
client_name = Column(String)
|
17 |
+
client_phone = Column(String)
|
18 |
+
address = Column(String)
|
19 |
+
total_ht = Column(Float)
|
20 |
+
tax = Column(Float)
|
21 |
+
total_ttc = Column(Float)
|
22 |
+
frame_number = Column(String, nullable=True)
|
23 |
+
customer_name = Column(String)
|
24 |
+
amount = Column(Float)
|
25 |
+
status = Column(String, default="pending")
|
26 |
+
created_at = Column(DateTime, default=datetime.utcnow)
|
27 |
+
|
28 |
+
items = relationship("InvoiceItem", back_populates="invoice")
|
29 |
+
|
30 |
+
class InvoiceItem(Base):
|
31 |
+
__tablename__ = "invoice_items"
|
32 |
+
|
33 |
+
id = Column(Integer, primary_key=True, index=True)
|
34 |
+
invoice_id = Column(Integer, ForeignKey("invoices.id"))
|
35 |
+
description = Column(String)
|
36 |
+
unit = Column(String)
|
37 |
+
quantity = Column(Integer)
|
38 |
+
length = Column(Float)
|
39 |
+
unit_price = Column(Float)
|
40 |
+
total_price = Column(Float)
|
41 |
+
|
42 |
+
invoice = relationship("Invoice", back_populates="items")
|
app/main.py
ADDED
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from fastapi import FastAPI, Request
|
2 |
+
from fastapi.staticfiles import StaticFiles
|
3 |
+
from fastapi.templating import Jinja2Templates
|
4 |
+
from .routes import invoices
|
5 |
+
from app.db.database import init_db
|
6 |
+
import os
|
7 |
+
from fastapi.middleware.cors import CORSMiddleware
|
8 |
+
from dotenv import load_dotenv
|
9 |
+
|
10 |
+
# Load environment variables
|
11 |
+
load_dotenv()
|
12 |
+
|
13 |
+
# Get the absolute path to the app directory
|
14 |
+
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
15 |
+
|
16 |
+
app = FastAPI(
|
17 |
+
title="Invoice Generator",
|
18 |
+
description="API for generating invoices",
|
19 |
+
version="1.0.0"
|
20 |
+
)
|
21 |
+
|
22 |
+
# Configure CORS
|
23 |
+
app.add_middleware(
|
24 |
+
CORSMiddleware,
|
25 |
+
allow_origins=["*"], # In production, replace with your frontend domain
|
26 |
+
allow_credentials=True,
|
27 |
+
allow_methods=["*"],
|
28 |
+
allow_headers=["*"],
|
29 |
+
)
|
30 |
+
|
31 |
+
# Mount static files with absolute path
|
32 |
+
app.mount("/static", StaticFiles(directory=os.path.join(BASE_DIR, "app/static")), name="static")
|
33 |
+
|
34 |
+
# Templates with absolute path
|
35 |
+
templates = Jinja2Templates(directory=os.path.join(BASE_DIR, "app/templates"))
|
36 |
+
|
37 |
+
# Include routers
|
38 |
+
app.include_router(invoices.router)
|
39 |
+
|
40 |
+
@app.on_event("startup")
|
41 |
+
async def startup_event():
|
42 |
+
await init_db()
|
43 |
+
|
44 |
+
# Root endpoint to serve the HTML page
|
45 |
+
@app.get("/")
|
46 |
+
async def root(request: Request):
|
47 |
+
return templates.TemplateResponse("index.html", {"request": request})
|
48 |
+
|
49 |
+
# Health check endpoint
|
50 |
+
@app.get("/health")
|
51 |
+
async def health_check():
|
52 |
+
return {"status": "healthy"}
|
app/models/invoice.py
ADDED
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from sqlalchemy import Column, Integer, String, DateTime, Float, ForeignKey, Sequence
|
2 |
+
from sqlalchemy.orm import relationship
|
3 |
+
from ..database import Base
|
4 |
+
|
5 |
+
class Invoice(Base):
|
6 |
+
__tablename__ = "invoices"
|
7 |
+
|
8 |
+
id = Column(Integer, Sequence('invoice_id_seq'), primary_key=True)
|
9 |
+
invoice_number = Column(String, unique=True)
|
10 |
+
# ... rest of your columns ...
|
app/routes/__init__.py
ADDED
@@ -0,0 +1 @@
|
|
|
|
|
1 |
+
# Empty file to make the directory a Python package
|
app/routes/__pycache__/__init__.cpython-312.pyc
ADDED
Binary file (199 Bytes). View file
|
|
app/routes/__pycache__/invoices.cpython-312.pyc
ADDED
Binary file (8.92 kB). View file
|
|
app/routes/invoice.py
ADDED
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from fastapi import APIRouter, Depends, HTTPException
|
2 |
+
from sqlalchemy.orm import Session
|
3 |
+
from sqlalchemy import func
|
4 |
+
from typing import Optional
|
5 |
+
from ..database import get_db
|
6 |
+
from ..models.invoice import Invoice, ClientType
|
7 |
+
|
8 |
+
router = APIRouter()
|
9 |
+
|
10 |
+
@router.get("/api/invoices/next-number/{client_type}")
|
11 |
+
async def get_next_invoice_number(client_type: str, db: Session = Depends(get_db)):
|
12 |
+
if client_type not in ClientType.__members__:
|
13 |
+
raise HTTPException(status_code=400, detail="Invalid client type")
|
14 |
+
|
15 |
+
# Get the last invoice number for this client type
|
16 |
+
last_invoice = db.query(Invoice)\
|
17 |
+
.filter(Invoice.client_type == ClientType[client_type])\
|
18 |
+
.order_by(Invoice.id.desc())\
|
19 |
+
.first()
|
20 |
+
|
21 |
+
if last_invoice:
|
22 |
+
# Extract the number from the last invoice number (DCP/TYPE/NUMBER)
|
23 |
+
try:
|
24 |
+
last_number = int(last_invoice.invoice_number.split('/')[-1])
|
25 |
+
next_number = last_number + 1
|
26 |
+
except (ValueError, IndexError):
|
27 |
+
next_number = 1
|
28 |
+
else:
|
29 |
+
next_number = 1
|
30 |
+
|
31 |
+
return {"next_number": f"{next_number:04d}"} # Format as 4 digits
|
32 |
+
|
33 |
+
@router.post("/api/invoices/")
|
34 |
+
async def create_invoice(invoice_data: InvoiceCreate, db: Session = Depends(get_db)):
|
35 |
+
# Extract client type from invoice number
|
36 |
+
try:
|
37 |
+
client_type = invoice_data.invoice_number.split('/')[1]
|
38 |
+
if client_type not in ClientType.__members__:
|
39 |
+
raise HTTPException(status_code=400, detail="Invalid client type in invoice number")
|
40 |
+
except IndexError:
|
41 |
+
raise HTTPException(status_code=400, detail="Invalid invoice number format")
|
42 |
+
|
43 |
+
db_invoice = Invoice(
|
44 |
+
client_type=ClientType[client_type],
|
45 |
+
**invoice_data.dict()
|
46 |
+
)
|
47 |
+
# ... rest of your existing create logic ...
|
app/routes/invoices.py
ADDED
@@ -0,0 +1,177 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from fastapi import APIRouter, Depends, HTTPException, Request
|
2 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
3 |
+
from sqlalchemy import select, func, Sequence
|
4 |
+
from typing import List, Optional
|
5 |
+
from sqlalchemy.orm import selectinload, joinedload, Session
|
6 |
+
from fastapi.responses import StreamingResponse
|
7 |
+
import logging
|
8 |
+
from datetime import datetime
|
9 |
+
|
10 |
+
from app.db.database import get_db
|
11 |
+
from app.schemas.invoice import InvoiceCreate, InvoiceResponse
|
12 |
+
from app.db.models import Invoice, InvoiceItem
|
13 |
+
from app.services.invoice_service import InvoiceService
|
14 |
+
|
15 |
+
# Set up logging
|
16 |
+
logger = logging.getLogger(__name__)
|
17 |
+
|
18 |
+
router = APIRouter(prefix="/api/invoices", tags=["invoices"])
|
19 |
+
|
20 |
+
@router.post("/", response_model=InvoiceResponse)
|
21 |
+
async def create_new_invoice(
|
22 |
+
invoice: InvoiceCreate,
|
23 |
+
db: AsyncSession = Depends(get_db)
|
24 |
+
):
|
25 |
+
try:
|
26 |
+
# Get current max ID
|
27 |
+
query = select(func.max(Invoice.id))
|
28 |
+
result = await db.execute(query)
|
29 |
+
current_max_id = result.scalar() or 0
|
30 |
+
next_id = current_max_id + 1
|
31 |
+
|
32 |
+
# Extract client type from the invoice number
|
33 |
+
client_type = invoice.invoice_number.split('/')[1]
|
34 |
+
|
35 |
+
# Create new invoice number
|
36 |
+
new_invoice_number = f"DCP/{client_type}/{next_id:04d}"
|
37 |
+
|
38 |
+
# Create invoice with the new number
|
39 |
+
db_invoice = Invoice(
|
40 |
+
id=next_id,
|
41 |
+
invoice_number=new_invoice_number,
|
42 |
+
date=invoice.date,
|
43 |
+
project=invoice.project,
|
44 |
+
client_name=invoice.client_name,
|
45 |
+
client_phone=invoice.client_phone,
|
46 |
+
address=invoice.address,
|
47 |
+
total_ht=invoice.total_ht,
|
48 |
+
tax=invoice.tax,
|
49 |
+
total_ttc=invoice.total_ttc,
|
50 |
+
frame_number=invoice.frame_number
|
51 |
+
)
|
52 |
+
|
53 |
+
db.add(db_invoice)
|
54 |
+
await db.commit()
|
55 |
+
await db.refresh(db_invoice)
|
56 |
+
|
57 |
+
# Create invoice items
|
58 |
+
for item_data in invoice.items:
|
59 |
+
db_item = InvoiceItem(
|
60 |
+
invoice_id=db_invoice.id,
|
61 |
+
description=item_data.description,
|
62 |
+
unit=item_data.unit,
|
63 |
+
quantity=item_data.quantity,
|
64 |
+
length=item_data.length,
|
65 |
+
unit_price=item_data.unit_price,
|
66 |
+
total_price=item_data.total_price
|
67 |
+
)
|
68 |
+
db.add(db_item)
|
69 |
+
|
70 |
+
await db.commit()
|
71 |
+
await db.refresh(db_invoice)
|
72 |
+
|
73 |
+
# Explicitly load the items relationship
|
74 |
+
result = await db.execute(
|
75 |
+
select(Invoice)
|
76 |
+
.options(selectinload(Invoice.items))
|
77 |
+
.filter(Invoice.id == db_invoice.id)
|
78 |
+
)
|
79 |
+
return result.scalar_one()
|
80 |
+
|
81 |
+
except Exception as e:
|
82 |
+
await db.rollback()
|
83 |
+
raise HTTPException(status_code=500, detail=str(e))
|
84 |
+
|
85 |
+
@router.get("/{invoice_id}", response_model=InvoiceResponse)
|
86 |
+
async def read_invoice(invoice_id: int, db: AsyncSession = Depends(get_db)):
|
87 |
+
result = await db.execute(
|
88 |
+
select(Invoice)
|
89 |
+
.options(selectinload(Invoice.items))
|
90 |
+
.filter(Invoice.id == invoice_id)
|
91 |
+
)
|
92 |
+
invoice = result.scalar_one_or_none()
|
93 |
+
if invoice is None:
|
94 |
+
raise HTTPException(status_code=404, detail="Invoice not found")
|
95 |
+
return invoice
|
96 |
+
|
97 |
+
@router.get("/", response_model=List[InvoiceResponse])
|
98 |
+
async def read_invoices(
|
99 |
+
skip: int = 0,
|
100 |
+
limit: int = 100,
|
101 |
+
db: AsyncSession = Depends(get_db)
|
102 |
+
):
|
103 |
+
result = await db.execute(
|
104 |
+
select(Invoice)
|
105 |
+
.options(selectinload(Invoice.items))
|
106 |
+
.offset(skip)
|
107 |
+
.limit(limit)
|
108 |
+
)
|
109 |
+
return result.scalars().all()
|
110 |
+
|
111 |
+
@router.post("/{invoice_id}/generate-pdf")
|
112 |
+
async def generate_invoice_pdf(
|
113 |
+
invoice_id: int,
|
114 |
+
request: Request,
|
115 |
+
db: AsyncSession = Depends(get_db)
|
116 |
+
):
|
117 |
+
try:
|
118 |
+
# Get invoice with items
|
119 |
+
result = await db.execute(
|
120 |
+
select(Invoice)
|
121 |
+
.options(selectinload(Invoice.items))
|
122 |
+
.filter(Invoice.id == invoice_id)
|
123 |
+
)
|
124 |
+
invoice = result.scalar_one_or_none()
|
125 |
+
|
126 |
+
if not invoice:
|
127 |
+
raise HTTPException(status_code=404, detail="Invoice not found")
|
128 |
+
|
129 |
+
logger.info(f"Generating PDF for invoice: {invoice_id}")
|
130 |
+
|
131 |
+
# Generate PDF
|
132 |
+
pdf_bytes = InvoiceService.generate_pdf(invoice)
|
133 |
+
|
134 |
+
return StreamingResponse(
|
135 |
+
iter([pdf_bytes]),
|
136 |
+
media_type="application/pdf",
|
137 |
+
headers={"Content-Disposition": f"attachment; filename=devis_{invoice_id}.pdf"}
|
138 |
+
)
|
139 |
+
except Exception as e:
|
140 |
+
logger.error(f"Error generating PDF: {str(e)}", exc_info=True)
|
141 |
+
raise HTTPException(status_code=500, detail=str(e))
|
142 |
+
|
143 |
+
@router.get("/last-number/{client_type}", response_model=dict)
|
144 |
+
async def get_last_invoice_number(
|
145 |
+
client_type: str,
|
146 |
+
db: AsyncSession = Depends(get_db)
|
147 |
+
):
|
148 |
+
try:
|
149 |
+
# Use async query syntax
|
150 |
+
query = select(func.max(Invoice.id))
|
151 |
+
result = await db.execute(query)
|
152 |
+
current_max_id = result.scalar() or 0
|
153 |
+
|
154 |
+
# Next ID will be current max + 1
|
155 |
+
next_id = current_max_id + 1
|
156 |
+
|
157 |
+
# Format the invoice number with the next ID
|
158 |
+
formatted_number = f"DCP/{client_type}/{next_id:04d}"
|
159 |
+
|
160 |
+
return {
|
161 |
+
"next_number": next_id,
|
162 |
+
"formatted_number": formatted_number
|
163 |
+
}
|
164 |
+
except Exception as e:
|
165 |
+
print(f"Error in get_last_invoice_number: {str(e)}")
|
166 |
+
raise HTTPException(status_code=500, detail=str(e))
|
167 |
+
|
168 |
+
@router.post("/reset-sequence")
|
169 |
+
def reset_invoice_sequence(db: Session = Depends(get_db)):
|
170 |
+
try:
|
171 |
+
# Reset the sequence to 1
|
172 |
+
db.execute("ALTER SEQUENCE invoice_id_seq RESTART WITH 1")
|
173 |
+
db.commit()
|
174 |
+
return {"message": "Sequence reset successfully"}
|
175 |
+
except Exception as e:
|
176 |
+
db.rollback()
|
177 |
+
raise HTTPException(status_code=500, detail=str(e))
|
app/schemas/__init__.py
ADDED
@@ -0,0 +1 @@
|
|
|
|
|
1 |
+
# Empty file to make the directory a Python package
|
app/schemas/__pycache__/__init__.cpython-312.pyc
ADDED
Binary file (200 Bytes). View file
|
|
app/schemas/__pycache__/invoice.cpython-312.pyc
ADDED
Binary file (2.75 kB). View file
|
|
app/schemas/invoice.py
ADDED
@@ -0,0 +1,49 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from pydantic import BaseModel, computed_field
|
2 |
+
from typing import List, Optional
|
3 |
+
from datetime import datetime
|
4 |
+
|
5 |
+
class InvoiceItemCreate(BaseModel):
|
6 |
+
description: str
|
7 |
+
unit: str
|
8 |
+
quantity: int
|
9 |
+
length: float
|
10 |
+
unit_price: float
|
11 |
+
|
12 |
+
@computed_field
|
13 |
+
def total_price(self) -> float:
|
14 |
+
return self.quantity * self.length * self.unit_price
|
15 |
+
|
16 |
+
class InvoiceCreate(BaseModel):
|
17 |
+
invoice_number: str
|
18 |
+
date: Optional[datetime] = None
|
19 |
+
project: str
|
20 |
+
client_name: str
|
21 |
+
client_phone: str
|
22 |
+
address: str
|
23 |
+
total_ht: float
|
24 |
+
tax: float
|
25 |
+
total_ttc: float
|
26 |
+
frame_number: Optional[str] = None
|
27 |
+
items: List[InvoiceItemCreate]
|
28 |
+
|
29 |
+
@computed_field
|
30 |
+
def customer_name(self) -> str:
|
31 |
+
return self.client_name
|
32 |
+
|
33 |
+
@computed_field
|
34 |
+
def amount(self) -> float:
|
35 |
+
return self.total_ttc
|
36 |
+
|
37 |
+
class InvoiceItemResponse(InvoiceItemCreate):
|
38 |
+
id: int
|
39 |
+
invoice_id: int
|
40 |
+
|
41 |
+
class Config:
|
42 |
+
from_attributes = True
|
43 |
+
|
44 |
+
class InvoiceResponse(InvoiceCreate):
|
45 |
+
id: int
|
46 |
+
items: List[InvoiceItemResponse]
|
47 |
+
|
48 |
+
class Config:
|
49 |
+
from_attributes = True
|
app/services/__pycache__/invoice_service.cpython-312.pyc
ADDED
Binary file (10.6 kB). View file
|
|
app/services/invoice_service.py
ADDED
@@ -0,0 +1,236 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from reportlab.lib.pagesizes import A4
|
2 |
+
from reportlab.pdfgen import canvas
|
3 |
+
from reportlab.lib import colors
|
4 |
+
from reportlab.lib.units import cm
|
5 |
+
from io import BytesIO
|
6 |
+
from app.db.models import Invoice
|
7 |
+
import logging
|
8 |
+
import os
|
9 |
+
|
10 |
+
# Set up logging
|
11 |
+
logger = logging.getLogger(__name__)
|
12 |
+
|
13 |
+
class InvoiceService:
|
14 |
+
@staticmethod
|
15 |
+
def generate_pdf(data: Invoice) -> bytes:
|
16 |
+
try:
|
17 |
+
buffer = BytesIO()
|
18 |
+
pdf = canvas.Canvas(buffer, pagesize=A4)
|
19 |
+
page_width, page_height = A4
|
20 |
+
|
21 |
+
# Constants
|
22 |
+
HEADER_BLUE = (0.29, 0.45, 0.68)
|
23 |
+
BLUE_LIGHT = (0.8, 0.8, 1)
|
24 |
+
WHITE = (1, 1, 1)
|
25 |
+
BLACK = (0, 0, 0)
|
26 |
+
MARGIN = 30
|
27 |
+
LINE_HEIGHT = 20
|
28 |
+
BOX_PADDING = 10
|
29 |
+
|
30 |
+
# Helper function to draw centered text
|
31 |
+
def draw_centered_text(pdf, text, x, y, width, font="Helvetica", size=10):
|
32 |
+
text_width = pdf.stringWidth(text, font, size)
|
33 |
+
pdf.drawString(x + (width - text_width) / 2, y, text)
|
34 |
+
|
35 |
+
# Helper function to draw a bordered box
|
36 |
+
def draw_box(pdf, x, y, width, height, fill_color=None, stroke_color=BLACK):
|
37 |
+
if fill_color:
|
38 |
+
pdf.setFillColorRGB(*fill_color)
|
39 |
+
pdf.rect(x, y, width, height, fill=1, stroke=0)
|
40 |
+
pdf.setFillColorRGB(*stroke_color)
|
41 |
+
pdf.rect(x, y, width, height, stroke=1)
|
42 |
+
|
43 |
+
# Get the absolute path to the logo file
|
44 |
+
current_dir = os.path.dirname(os.path.abspath(__file__))
|
45 |
+
logo_path = os.path.join(current_dir, "..", "static", "logo.png")
|
46 |
+
|
47 |
+
# Top section layout
|
48 |
+
top_margin = page_height - 100
|
49 |
+
|
50 |
+
# Left side: Logo - moved far left and up
|
51 |
+
if os.path.exists(logo_path):
|
52 |
+
pdf.drawImage(logo_path, MARGIN, top_margin + 30, width=100, height=60)
|
53 |
+
|
54 |
+
# Right side: DEVIS and Client Box - moved far right and up
|
55 |
+
pdf.setFont("Helvetica-Bold", 48)
|
56 |
+
devis_text = "DEVIS"
|
57 |
+
devis_width = pdf.stringWidth(devis_text, "Helvetica-Bold", 48)
|
58 |
+
devis_x = page_width - devis_width - MARGIN - 10
|
59 |
+
devis_y = top_margin + 50
|
60 |
+
pdf.drawString(devis_x, devis_y, devis_text)
|
61 |
+
|
62 |
+
# Client info box - moved right under DEVIS
|
63 |
+
box_width = 200
|
64 |
+
box_height = 80
|
65 |
+
box_x = page_width - box_width - MARGIN + 10
|
66 |
+
box_y = devis_y - 90
|
67 |
+
|
68 |
+
# Draw client box
|
69 |
+
pdf.rect(box_x, box_y, box_width, box_height, stroke=1)
|
70 |
+
|
71 |
+
# Client Info
|
72 |
+
pdf.setFont("Helvetica", 10)
|
73 |
+
client_info = [
|
74 |
+
data.client_name,
|
75 |
+
data.project,
|
76 |
+
data.address,
|
77 |
+
data.client_phone
|
78 |
+
]
|
79 |
+
|
80 |
+
# Center and draw each line of client info
|
81 |
+
line_height = box_height / (len(client_info) + 1)
|
82 |
+
for i, text in enumerate(client_info):
|
83 |
+
text_width = pdf.stringWidth(str(text), "Helvetica", 10)
|
84 |
+
x = box_x + (box_width - text_width) / 2
|
85 |
+
y = box_y + box_height - ((i + 1) * line_height)
|
86 |
+
pdf.drawString(x, y, str(text))
|
87 |
+
|
88 |
+
# Info boxes (Date, N° Devis, PLANCHER) - adjusted starting position
|
89 |
+
info_y = top_margin
|
90 |
+
box_label_width = 120
|
91 |
+
box_value_width = 80
|
92 |
+
|
93 |
+
for label, value in [
|
94 |
+
("Date du devis :", data.date.strftime("%d/%m/%Y")),
|
95 |
+
("N° Devis :", data.invoice_number),
|
96 |
+
("PLANCHER :", data.frame_number or "PH RDC")
|
97 |
+
]:
|
98 |
+
draw_box(pdf, MARGIN, info_y, box_label_width, LINE_HEIGHT, fill_color=HEADER_BLUE)
|
99 |
+
pdf.setFillColorRGB(*WHITE)
|
100 |
+
pdf.drawString(MARGIN + BOX_PADDING, info_y + 6, label)
|
101 |
+
|
102 |
+
draw_box(pdf, MARGIN + box_label_width, info_y, box_value_width, LINE_HEIGHT, fill_color=WHITE)
|
103 |
+
pdf.setFillColorRGB(*BLACK)
|
104 |
+
draw_centered_text(pdf, str(value), MARGIN + box_label_width, info_y + 6, box_value_width)
|
105 |
+
|
106 |
+
info_y -= 25
|
107 |
+
|
108 |
+
# Table headers
|
109 |
+
table_y = info_y - 30
|
110 |
+
headers = [
|
111 |
+
("Description", 150),
|
112 |
+
("Unité", 50),
|
113 |
+
("NBRE", 50),
|
114 |
+
("LNG/Qté", 60),
|
115 |
+
("P.U", 60),
|
116 |
+
("Total HT", 170)
|
117 |
+
]
|
118 |
+
|
119 |
+
total_width = sum(width for _, width in headers)
|
120 |
+
table_x = (page_width - total_width) / 2 # Center table
|
121 |
+
draw_box(pdf, table_x, table_y, total_width, LINE_HEIGHT, fill_color=HEADER_BLUE)
|
122 |
+
pdf.setFillColorRGB(*WHITE)
|
123 |
+
|
124 |
+
current_x = table_x
|
125 |
+
for title, width in headers:
|
126 |
+
draw_box(pdf, current_x, table_y, width, LINE_HEIGHT)
|
127 |
+
draw_centered_text(pdf, title, current_x, table_y + 6, width)
|
128 |
+
current_x += width
|
129 |
+
|
130 |
+
|
131 |
+
# Draw sections and items
|
132 |
+
current_y = table_y - LINE_HEIGHT
|
133 |
+
|
134 |
+
def draw_section_header(title):
|
135 |
+
nonlocal current_y
|
136 |
+
draw_box(pdf, table_x, current_y, total_width, LINE_HEIGHT, fill_color=BLUE_LIGHT)
|
137 |
+
pdf.setFillColorRGB(*BLACK)
|
138 |
+
pdf.drawString(table_x + BOX_PADDING, current_y + 6, title)
|
139 |
+
current_y -= LINE_HEIGHT
|
140 |
+
|
141 |
+
def draw_section_header2(title):
|
142 |
+
nonlocal current_y
|
143 |
+
draw_box(pdf, table_x, current_y, total_width, LINE_HEIGHT, fill_color=WHITE)
|
144 |
+
|
145 |
+
# Set the font to a bold variant
|
146 |
+
pdf.setFont("Helvetica-Bold", 12) # Adjust the font name and size as needed
|
147 |
+
|
148 |
+
# Set the fill color to black
|
149 |
+
pdf.setFillColorRGB(0, 0, 0) # RGB values for black
|
150 |
+
|
151 |
+
# Draw the string
|
152 |
+
pdf.drawString(table_x + BOX_PADDING, current_y + 6, title)
|
153 |
+
|
154 |
+
current_y -= LINE_HEIGHT
|
155 |
+
|
156 |
+
def draw_item_row(item, indent=False):
|
157 |
+
nonlocal current_y
|
158 |
+
pdf.setFillColorRGB(*BLACK)
|
159 |
+
current_x = table_x
|
160 |
+
|
161 |
+
draw_box(pdf, current_x, current_y, total_width, LINE_HEIGHT, fill_color=WHITE)
|
162 |
+
|
163 |
+
cells = [
|
164 |
+
(" " + item.description if indent else item.description, 150),
|
165 |
+
(item.unit, 50),
|
166 |
+
(str(item.quantity), 50),
|
167 |
+
(f"{item.length:.2f}", 60),
|
168 |
+
(f"{item.unit_price:.2f}", 60),
|
169 |
+
(f"{item.total_price:.2f}", 170)
|
170 |
+
]
|
171 |
+
|
172 |
+
for value, width in cells:
|
173 |
+
draw_box(pdf, current_x, current_y, width, LINE_HEIGHT)
|
174 |
+
if isinstance(value, str) and value.startswith(" "):
|
175 |
+
pdf.drawString(current_x + 20, current_y + 6, value.strip())
|
176 |
+
else:
|
177 |
+
draw_centered_text(pdf, str(value), current_x, current_y + 6, width)
|
178 |
+
current_x += width
|
179 |
+
|
180 |
+
current_y -= LINE_HEIGHT
|
181 |
+
|
182 |
+
# Draw sections
|
183 |
+
sections = [
|
184 |
+
("POUTRELLES", "PCP"),
|
185 |
+
("HOURDIS", "HOURDIS"),
|
186 |
+
("PANNEAU TREILLIS SOUDES", "PTS")
|
187 |
+
]
|
188 |
+
|
189 |
+
for section_title, keyword in sections:
|
190 |
+
draw_section_header(section_title)
|
191 |
+
items = [i for i in data.items if keyword in i.description]
|
192 |
+
for item in items:
|
193 |
+
draw_item_row(item, indent=(keyword != "lfflflflf"))
|
194 |
+
|
195 |
+
# NB box with just "NB:" text
|
196 |
+
nb_box_width = 200
|
197 |
+
nb_box_height = 80
|
198 |
+
pdf.setFillColorRGB(*BLACK)
|
199 |
+
pdf.rect(20, current_y - nb_box_height, nb_box_width, nb_box_height, stroke=1)
|
200 |
+
pdf.setFont("Helvetica-Bold", 12) # Made slightly larger for better visibility
|
201 |
+
pdf.drawString(60, current_y - nb_box_height + 60, "NB:")
|
202 |
+
|
203 |
+
# Totals section
|
204 |
+
current_y -= 20
|
205 |
+
totals_table_width = 300
|
206 |
+
row_height = 20
|
207 |
+
|
208 |
+
for i, (label1, label2, value) in enumerate([
|
209 |
+
("Total", "H.T", f"{data.total_ht:.2f} DH"),
|
210 |
+
("TVA", "20 %", f"{data.tax:.2f} DH"),
|
211 |
+
("Total", "TTC", f"{data.total_ttc:.2f} DH")
|
212 |
+
]):
|
213 |
+
y = current_y - (i * row_height)
|
214 |
+
totals_x = (page_width - totals_table_width) - 27 # Center totals table
|
215 |
+
draw_box(pdf, totals_x, y, totals_table_width / 2, row_height)
|
216 |
+
draw_box(pdf, totals_x + totals_table_width / 2, y, totals_table_width / 2, row_height)
|
217 |
+
pdf.drawString(totals_x + 10, y + 6, f"{label1} {label2}")
|
218 |
+
pdf.drawRightString(totals_x + totals_table_width - 10, y + 6, value)
|
219 |
+
|
220 |
+
# Footer
|
221 |
+
pdf.setFont("Helvetica", 8)
|
222 |
+
if os.path.exists(logo_path):
|
223 |
+
pdf.drawImage(logo_path, MARGIN, 20, width=30, height=15)
|
224 |
+
|
225 |
+
footer_text = "Douar Ait Laarassi Tidili, Cercle El Kelâa, Route de Safi, Km 14-40000 Marrakech"
|
226 |
+
pdf.drawCentredString(page_width / 2, 30, footer_text)
|
227 |
+
footer_contact = "Tél: 05 24 01 55 54 Fax : 05 24 01 55 29 E-mail : [email protected]"
|
228 |
+
pdf.drawCentredString(page_width / 2, 20, footer_contact)
|
229 |
+
|
230 |
+
pdf.save()
|
231 |
+
buffer.seek(0)
|
232 |
+
return buffer.getvalue()
|
233 |
+
|
234 |
+
except Exception as e:
|
235 |
+
logger.error(f"Error in PDF generation: {str(e)}", exc_info=True)
|
236 |
+
raise
|
app/static/logo.png
ADDED
![]() |
app/templates/index.html
ADDED
@@ -0,0 +1,582 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
<!DOCTYPE html>
|
2 |
+
<html lang="en">
|
3 |
+
<head>
|
4 |
+
<meta charset="UTF-8">
|
5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
6 |
+
<title>Invoice Generator</title>
|
7 |
+
<!-- Bootstrap CSS -->
|
8 |
+
<link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" rel="stylesheet">
|
9 |
+
<style>
|
10 |
+
.table-fixed {
|
11 |
+
table-layout: fixed;
|
12 |
+
}
|
13 |
+
.table-fixed td {
|
14 |
+
overflow: hidden;
|
15 |
+
text-overflow: ellipsis;
|
16 |
+
white-space: nowrap;
|
17 |
+
}
|
18 |
+
.section-title {
|
19 |
+
background-color: #f8f9fa;
|
20 |
+
padding: 10px;
|
21 |
+
margin-top: 20px;
|
22 |
+
margin-bottom: 10px;
|
23 |
+
border-radius: 5px;
|
24 |
+
}
|
25 |
+
</style>
|
26 |
+
</head>
|
27 |
+
<body>
|
28 |
+
<div class="container mt-4">
|
29 |
+
<h2 class="mb-4">Générateur de Devis</h2>
|
30 |
+
|
31 |
+
<form id="invoiceForm">
|
32 |
+
<!-- Client Information -->
|
33 |
+
<div class="row mb-4">
|
34 |
+
<div class="col-md-6">
|
35 |
+
<div class="card">
|
36 |
+
<div class="card-header">
|
37 |
+
Information Client
|
38 |
+
</div>
|
39 |
+
<div class="card-body">
|
40 |
+
<div class="mb-3">
|
41 |
+
<label for="clientName" class="form-label">Nom du Client</label>
|
42 |
+
<input type="text" class="form-control" id="clientName" required>
|
43 |
+
</div>
|
44 |
+
<div class="mb-3">
|
45 |
+
<label for="clientPhone" class="form-label">Téléphone</label>
|
46 |
+
<input type="tel" class="form-control" id="clientPhone" required>
|
47 |
+
</div>
|
48 |
+
<div class="mb-3">
|
49 |
+
<label for="clientAddress" class="form-label">Adresse</label>
|
50 |
+
<input type="text" class="form-control" id="clientAddress" required>
|
51 |
+
</div>
|
52 |
+
<div class="mb-3">
|
53 |
+
<label class="form-label">Type Client</label>
|
54 |
+
<div class="btn-group" role="group">
|
55 |
+
<input type="radio" class="btn-check" name="clientType" id="EE" value="EE" autocomplete="off">
|
56 |
+
<label class="btn btn-outline-primary" for="EE">EE</label>
|
57 |
+
|
58 |
+
<input type="radio" class="btn-check" name="clientType" id="MED" value="MED" autocomplete="off">
|
59 |
+
<label class="btn btn-outline-primary" for="MED">MED</label>
|
60 |
+
|
61 |
+
<input type="radio" class="btn-check" name="clientType" id="AM" value="AM" autocomplete="off">
|
62 |
+
<label class="btn btn-outline-primary" for="AM">AM</label>
|
63 |
+
|
64 |
+
<input type="radio" class="btn-check" name="clientType" id="DIV" value="DIV" autocomplete="off">
|
65 |
+
<label class="btn btn-outline-primary" for="DIV">DIV</label>
|
66 |
+
</div>
|
67 |
+
</div>
|
68 |
+
</div>
|
69 |
+
</div>
|
70 |
+
</div>
|
71 |
+
|
72 |
+
<div class="col-md-6">
|
73 |
+
<div class="card">
|
74 |
+
<div class="card-header">
|
75 |
+
Information Devis
|
76 |
+
</div>
|
77 |
+
<div class="card-body">
|
78 |
+
<div class="mb-3">
|
79 |
+
<label for="invoiceNumber" class="form-label">N° Devis</label>
|
80 |
+
<input type="text" class="form-control" id="invoiceNumber" required>
|
81 |
+
</div>
|
82 |
+
<div class="mb-3">
|
83 |
+
<label for="date" class="form-label">Date</label>
|
84 |
+
<input type="date" class="form-control" id="date" readonly>
|
85 |
+
</div>
|
86 |
+
<div class="mb-3">
|
87 |
+
<label for="project" class="form-label">Type d'ouvrage</label>
|
88 |
+
<input type="text" class="form-control" id="project" required>
|
89 |
+
</div>
|
90 |
+
</div>
|
91 |
+
</div>
|
92 |
+
</div>
|
93 |
+
</div>
|
94 |
+
|
95 |
+
<!-- Poutrelles Section -->
|
96 |
+
<div class="section-title d-flex justify-content-between align-items-center">
|
97 |
+
<h4>POUTRELLES</h4>
|
98 |
+
<button type="button" class="btn btn-primary" id="addPoutrelles">
|
99 |
+
Ajouter Poutrelle
|
100 |
+
</button>
|
101 |
+
</div>
|
102 |
+
<div class="table-responsive">
|
103 |
+
<table class="table table-bordered table-fixed">
|
104 |
+
<thead class="table-primary">
|
105 |
+
<tr>
|
106 |
+
<th style="width: 30%">Description</th>
|
107 |
+
<th style="width: 10%">Unité</th>
|
108 |
+
<th style="width: 10%">Quantité</th>
|
109 |
+
<th style="width: 15%">Longueur</th>
|
110 |
+
<th style="width: 15%">P.U</th>
|
111 |
+
<th style="width: 15%">Total HT</th>
|
112 |
+
<th style="width: 5%"></th>
|
113 |
+
</tr>
|
114 |
+
</thead>
|
115 |
+
<tbody id="poutrellesTable"></tbody>
|
116 |
+
</table>
|
117 |
+
</div>
|
118 |
+
|
119 |
+
<!-- Hourdis Section -->
|
120 |
+
<div class="section-title d-flex justify-content-between align-items-center">
|
121 |
+
<h4>HOURDIS</h4>
|
122 |
+
<button type="button" class="btn btn-primary" id="addHourdis">
|
123 |
+
Ajouter Hourdis
|
124 |
+
</button>
|
125 |
+
</div>
|
126 |
+
<div class="table-responsive">
|
127 |
+
<table class="table table-bordered table-fixed">
|
128 |
+
<thead class="table-primary">
|
129 |
+
<tr>
|
130 |
+
<th style="width: 30%">Description</th>
|
131 |
+
<th style="width: 10%">Unité</th>
|
132 |
+
<th style="width: 10%">Quantité</th>
|
133 |
+
<th style="width: 15%">Longueur</th>
|
134 |
+
<th style="width: 15%">P.U</th>
|
135 |
+
<th style="width: 15%">Total HT</th>
|
136 |
+
<th style="width: 5%"></th>
|
137 |
+
</tr>
|
138 |
+
</thead>
|
139 |
+
<tbody id="hourdisTable"></tbody>
|
140 |
+
</table>
|
141 |
+
</div>
|
142 |
+
|
143 |
+
<!-- Panneau Section -->
|
144 |
+
<div class="section-title d-flex justify-content-between align-items-center">
|
145 |
+
<h4>PANNEAU TREILLIS SOUDES</h4>
|
146 |
+
<button type="button" class="btn btn-primary" id="addPanneau">
|
147 |
+
Ajouter Panneau
|
148 |
+
</button>
|
149 |
+
</div>
|
150 |
+
<div class="table-responsive">
|
151 |
+
<table class="table table-bordered table-fixed">
|
152 |
+
<thead class="table-primary">
|
153 |
+
<tr>
|
154 |
+
<th style="width: 30%">Description</th>
|
155 |
+
<th style="width: 10%">Unité</th>
|
156 |
+
<th style="width: 10%">Quantité</th>
|
157 |
+
<th style="width: 15%">Longueur</th>
|
158 |
+
<th style="width: 15%">P.U</th>
|
159 |
+
<th style="width: 15%">Total HT</th>
|
160 |
+
<th style="width: 5%"></th>
|
161 |
+
</tr>
|
162 |
+
</thead>
|
163 |
+
<tbody id="panneauTable"></tbody>
|
164 |
+
</table>
|
165 |
+
</div>
|
166 |
+
|
167 |
+
<!-- Totals -->
|
168 |
+
<div class="row mt-4">
|
169 |
+
<div class="col-md-6">
|
170 |
+
<!-- Empty for spacing -->
|
171 |
+
</div>
|
172 |
+
<div class="col-md-6">
|
173 |
+
<div class="card">
|
174 |
+
<div class="card-body">
|
175 |
+
<div class="mb-3">
|
176 |
+
<label for="totalHT" class="form-label">Total HT</label>
|
177 |
+
<input type="number" class="form-control" id="totalHT" readonly>
|
178 |
+
</div>
|
179 |
+
<div class="mb-3">
|
180 |
+
<label for="tax" class="form-label">TVA 20%</label>
|
181 |
+
<input type="number" class="form-control" id="tax" readonly>
|
182 |
+
</div>
|
183 |
+
<div class="mb-3">
|
184 |
+
<label for="totalTTC" class="form-label">Total TTC</label>
|
185 |
+
<input type="number" class="form-control" id="totalTTC" readonly>
|
186 |
+
</div>
|
187 |
+
</div>
|
188 |
+
</div>
|
189 |
+
</div>
|
190 |
+
</div>
|
191 |
+
|
192 |
+
<div class="d-grid gap-2 col-md-6 mx-auto mt-4">
|
193 |
+
<button type="submit" class="btn btn-success btn-lg">Générer le Devis</button>
|
194 |
+
</div>
|
195 |
+
</form>
|
196 |
+
</div>
|
197 |
+
|
198 |
+
<!-- Poutrelles Modal -->
|
199 |
+
<div class="modal fade" id="poutrellesModal" tabindex="-1">
|
200 |
+
<div class="modal-dialog modal-lg">
|
201 |
+
<div class="modal-content">
|
202 |
+
<div class="modal-header">
|
203 |
+
<h5 class="modal-title">Sélectionner Poutrelle</h5>
|
204 |
+
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
205 |
+
</div>
|
206 |
+
<div class="modal-body">
|
207 |
+
<div class="table-responsive">
|
208 |
+
<table class="table">
|
209 |
+
<thead>
|
210 |
+
<tr>
|
211 |
+
<th>Description</th>
|
212 |
+
<th>Unité</th>
|
213 |
+
<th>Quantité</th>
|
214 |
+
<th>Longueur</th>
|
215 |
+
<th>P.U</th>
|
216 |
+
<th>Action</th>
|
217 |
+
</tr>
|
218 |
+
</thead>
|
219 |
+
<tbody></tbody>
|
220 |
+
</table>
|
221 |
+
</div>
|
222 |
+
</div>
|
223 |
+
</div>
|
224 |
+
</div>
|
225 |
+
</div>
|
226 |
+
|
227 |
+
<!-- Hourdis Modal -->
|
228 |
+
<div class="modal fade" id="hourdisModal" tabindex="-1">
|
229 |
+
<div class="modal-dialog modal-lg">
|
230 |
+
<div class="modal-content">
|
231 |
+
<div class="modal-header">
|
232 |
+
<h5 class="modal-title">Sélectionner Hourdis</h5>
|
233 |
+
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
234 |
+
</div>
|
235 |
+
<div class="modal-body">
|
236 |
+
<div class="table-responsive">
|
237 |
+
<table class="table">
|
238 |
+
<thead>
|
239 |
+
<tr>
|
240 |
+
<th>Description</th>
|
241 |
+
<th>Unité</th>
|
242 |
+
<th>Quantité</th>
|
243 |
+
<th>Longueur</th>
|
244 |
+
<th>P.U</th>
|
245 |
+
<th>Action</th>
|
246 |
+
</tr>
|
247 |
+
</thead>
|
248 |
+
<tbody></tbody>
|
249 |
+
</table>
|
250 |
+
</div>
|
251 |
+
</div>
|
252 |
+
</div>
|
253 |
+
</div>
|
254 |
+
</div>
|
255 |
+
|
256 |
+
<!-- Panneau Modal -->
|
257 |
+
<div class="modal fade" id="panneauModal" tabindex="-1">
|
258 |
+
<div class="modal-dialog modal-lg">
|
259 |
+
<div class="modal-content">
|
260 |
+
<div class="modal-header">
|
261 |
+
<h5 class="modal-title">Sélectionner Panneau</h5>
|
262 |
+
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
263 |
+
</div>
|
264 |
+
<div class="modal-body">
|
265 |
+
<div class="table-responsive">
|
266 |
+
<table class="table">
|
267 |
+
<thead>
|
268 |
+
<tr>
|
269 |
+
<th>Description</th>
|
270 |
+
<th>Unité</th>
|
271 |
+
<th>Quantité</th>
|
272 |
+
<th>Longueur</th>
|
273 |
+
<th>P.U</th>
|
274 |
+
<th>Action</th>
|
275 |
+
</tr>
|
276 |
+
</thead>
|
277 |
+
<tbody></tbody>
|
278 |
+
</table>
|
279 |
+
</div>
|
280 |
+
</div>
|
281 |
+
</div>
|
282 |
+
</div>
|
283 |
+
</div>
|
284 |
+
|
285 |
+
<!-- Bootstrap JS -->
|
286 |
+
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/js/bootstrap.bundle.min.js"></script>
|
287 |
+
|
288 |
+
<script>
|
289 |
+
document.addEventListener("DOMContentLoaded", function() {
|
290 |
+
const poutrellesTable = document.getElementById("poutrellesTable");
|
291 |
+
const hourdisTable = document.getElementById("hourdisTable");
|
292 |
+
const panneauTable = document.getElementById("panneauTable");
|
293 |
+
const invoiceForm = document.getElementById("invoiceForm");
|
294 |
+
|
295 |
+
// Default items data
|
296 |
+
const defaultItems = {
|
297 |
+
poutrelles: [
|
298 |
+
{ description: "PCP 114N", unit: "ML", quantity: 21, length: 3.00, unit_price: 26.00 },
|
299 |
+
{ description: "PCP 113N", unit: "ML", quantity: 12, length: 4.00, unit_price: 26.00 }
|
300 |
+
],
|
301 |
+
hourdis: [
|
302 |
+
{ description: "HOURDIS TYPE 12", unit: "U", quantity: 1, length: 300, unit_price: 3.50 }
|
303 |
+
],
|
304 |
+
panneaux: [
|
305 |
+
{ description: "PTS SISMIQUE 5*3,5", unit: "U", quantity: 1, length: 9, unit_price: 195.00 }
|
306 |
+
]
|
307 |
+
};
|
308 |
+
|
309 |
+
// Function to add a new item row
|
310 |
+
function addItemRow(item, tableBody) {
|
311 |
+
const row = document.createElement("tr");
|
312 |
+
const total = (item.quantity * item.length * item.unit_price).toFixed(2);
|
313 |
+
|
314 |
+
row.innerHTML = `
|
315 |
+
<td><input type="text" class="form-control" name="description" value="${item.description}" required></td>
|
316 |
+
<td><input type="text" class="form-control" name="unit" value="${item.unit}" required></td>
|
317 |
+
<td><input type="number" class="form-control" name="quantity" value="${item.quantity}" required></td>
|
318 |
+
<td><input type="number" class="form-control" name="length" value="${item.length}" step="0.01" required></td>
|
319 |
+
<td><input type="number" class="form-control" name="unitPrice" value="${item.unit_price}" step="0.01" required></td>
|
320 |
+
<td><input type="number" class="form-control" name="totalHT" value="${total}" readonly></td>
|
321 |
+
<td><button type="button" class="btn btn-danger btn-sm remove-item">×</button></td>
|
322 |
+
`;
|
323 |
+
|
324 |
+
row.querySelector(".remove-item").addEventListener("click", () => {
|
325 |
+
row.remove();
|
326 |
+
updateTotals();
|
327 |
+
});
|
328 |
+
|
329 |
+
// Add input event listeners to update total
|
330 |
+
['quantity', 'length', 'unitPrice'].forEach(field => {
|
331 |
+
row.querySelector(`input[name='${field}']`).addEventListener('input', () => {
|
332 |
+
const qty = parseFloat(row.querySelector('input[name="quantity"]').value) || 0;
|
333 |
+
const length = parseFloat(row.querySelector('input[name="length"]').value) || 0;
|
334 |
+
const price = parseFloat(row.querySelector('input[name="unitPrice"]').value) || 0;
|
335 |
+
const rowTotal = (qty * length * price).toFixed(2);
|
336 |
+
row.querySelector('input[name="totalHT"]').value = rowTotal;
|
337 |
+
updateTotals();
|
338 |
+
});
|
339 |
+
});
|
340 |
+
|
341 |
+
tableBody.appendChild(row);
|
342 |
+
updateTotals();
|
343 |
+
}
|
344 |
+
|
345 |
+
// Function to update totals
|
346 |
+
function updateTotals() {
|
347 |
+
const totalHT = Array.from(document.querySelectorAll('input[name="totalHT"]'))
|
348 |
+
.reduce((sum, input) => sum + (parseFloat(input.value) || 0), 0);
|
349 |
+
|
350 |
+
const formattedTotalHT = totalHT.toFixed(2);
|
351 |
+
const tax = (totalHT * 0.20).toFixed(2);
|
352 |
+
const totalTTC = (totalHT * 1.20).toFixed(2);
|
353 |
+
|
354 |
+
document.querySelector("#totalHT").value = formattedTotalHT;
|
355 |
+
document.querySelector("#tax").value = tax;
|
356 |
+
document.querySelector("#totalTTC").value = totalTTC;
|
357 |
+
}
|
358 |
+
|
359 |
+
// Initialize tables with default items
|
360 |
+
defaultItems.poutrelles.forEach(item => addItemRow(item, poutrellesTable));
|
361 |
+
defaultItems.hourdis.forEach(item => addItemRow(item, hourdisTable));
|
362 |
+
defaultItems.panneaux.forEach(item => addItemRow(item, panneauTable));
|
363 |
+
|
364 |
+
// Update initial totals
|
365 |
+
updateTotals();
|
366 |
+
|
367 |
+
// Predefined items data
|
368 |
+
const predefinedItems = {
|
369 |
+
poutrelles: [
|
370 |
+
{ description: "PCP 114N", unit: "ML", quantity: 21, length: 3.00, unit_price: 26.00 },
|
371 |
+
{ description: "PCP 113N", unit: "ML", quantity: 12, length: 4.00, unit_price: 26.00 },
|
372 |
+
{ description: "PCP 113B SISMIQUE", unit: "ML", quantity: 22, length: 4.20, unit_price: 26.00 },
|
373 |
+
{ description: "PCP 114B SISMIQUE", unit: "ML", quantity: 12, length: 4.90, unit_price: 30.00 },
|
374 |
+
{ description: "PCP 135 SISMIQUE", unit: "ML", quantity: 8, length: 5.00, unit_price: 38.00 },
|
375 |
+
{ description: "PCP 156 SISMIQUE", unit: "ML", quantity: 12, length: 5.10, unit_price: 38.00 },
|
376 |
+
{ description: "PCP 158 SISMIQUE", unit: "ML", quantity: 12, length: 5.50, unit_price: 51.00 }
|
377 |
+
],
|
378 |
+
hourdis: [
|
379 |
+
{ description: "HOURDIS TYPE 08", unit: "U", quantity: 1, length: 200, unit_price: 3.40 },
|
380 |
+
{ description: "HOURDIS TYPE 12", unit: "U", quantity: 1, length: 300, unit_price: 3.50 },
|
381 |
+
{ description: "HOURDIS TYPE 16", unit: "U", quantity: 1, length: 0, unit_price: 0 },
|
382 |
+
{ description: "HOURDIS TYPE 20", unit: "U", quantity: 1, length: 0, unit_price: 0 },
|
383 |
+
{ description: "HOURDIS TYPE 25", unit: "U", quantity: 1, length: 450, unit_price: 4.80 },
|
384 |
+
{ description: "HOURDIS TYPE 30", unit: "U", quantity: 1, length: 0, unit_price: 0 }
|
385 |
+
],
|
386 |
+
panneaux: [
|
387 |
+
{ description: "PTS Normal 3,5*3,5", unit: "U", quantity: 1, length: 0, unit_price: 0 },
|
388 |
+
{ description: "PTS SISMIQUE 5*3,5", unit: "U", quantity: 1, length: 9, unit_price: 195.00 }
|
389 |
+
]
|
390 |
+
};
|
391 |
+
|
392 |
+
// Function to populate modal with items
|
393 |
+
function populateModal(modalId, items, targetTable) {
|
394 |
+
const modal = document.querySelector(modalId);
|
395 |
+
const tbody = modal.querySelector('tbody');
|
396 |
+
tbody.innerHTML = '';
|
397 |
+
|
398 |
+
items.forEach(item => {
|
399 |
+
const row = document.createElement('tr');
|
400 |
+
row.innerHTML = `
|
401 |
+
<td>${item.description}</td>
|
402 |
+
<td>${item.unit}</td>
|
403 |
+
<td><input type="number" class="form-control form-control-sm" value="${item.quantity}" name="modal-quantity"></td>
|
404 |
+
<td><input type="number" class="form-control form-control-sm" value="${item.length}" step="0.01" name="modal-length"></td>
|
405 |
+
<td><input type="number" class="form-control form-control-sm" value="${item.unit_price}" step="0.01" name="modal-price"></td>
|
406 |
+
<td>
|
407 |
+
<button class="btn btn-primary btn-sm add-item">
|
408 |
+
Ajouter
|
409 |
+
</button>
|
410 |
+
</td>
|
411 |
+
`;
|
412 |
+
|
413 |
+
row.querySelector('.add-item').addEventListener('click', () => {
|
414 |
+
const newItem = {
|
415 |
+
...item,
|
416 |
+
quantity: parseFloat(row.querySelector('[name="modal-quantity"]').value) || 0,
|
417 |
+
length: parseFloat(row.querySelector('[name="modal-length"]').value) || 0,
|
418 |
+
unit_price: parseFloat(row.querySelector('[name="modal-price"]').value) || 0
|
419 |
+
};
|
420 |
+
addItemRow(newItem, targetTable);
|
421 |
+
bootstrap.Modal.getInstance(modal).hide();
|
422 |
+
});
|
423 |
+
|
424 |
+
tbody.appendChild(row);
|
425 |
+
});
|
426 |
+
}
|
427 |
+
|
428 |
+
// Update button click handlers
|
429 |
+
document.querySelector("#addPoutrelles").addEventListener("click", () => {
|
430 |
+
populateModal('#poutrellesModal', predefinedItems.poutrelles, poutrellesTable);
|
431 |
+
new bootstrap.Modal('#poutrellesModal').show();
|
432 |
+
});
|
433 |
+
|
434 |
+
document.querySelector("#addHourdis").addEventListener("click", () => {
|
435 |
+
populateModal('#hourdisModal', predefinedItems.hourdis, hourdisTable);
|
436 |
+
new bootstrap.Modal('#hourdisModal').show();
|
437 |
+
});
|
438 |
+
|
439 |
+
document.querySelector("#addPanneau").addEventListener("click", () => {
|
440 |
+
populateModal('#panneauModal', predefinedItems.panneaux, panneauTable);
|
441 |
+
new bootstrap.Modal('#panneauModal').show();
|
442 |
+
});
|
443 |
+
|
444 |
+
// Form submission
|
445 |
+
invoiceForm.addEventListener("submit", async (event) => {
|
446 |
+
event.preventDefault();
|
447 |
+
|
448 |
+
const getAllItems = () => {
|
449 |
+
const items = [];
|
450 |
+
[poutrellesTable, hourdisTable, panneauTable].forEach(table => {
|
451 |
+
items.push(...Array.from(table.querySelectorAll("tr")).map(row => ({
|
452 |
+
description: row.querySelector("input[name='description']").value,
|
453 |
+
unit: row.querySelector("input[name='unit']").value,
|
454 |
+
quantity: parseInt(row.querySelector("input[name='quantity']").value, 10),
|
455 |
+
length: parseFloat(row.querySelector("input[name='length']").value),
|
456 |
+
unit_price: parseFloat(row.querySelector("input[name='unitPrice']").value),
|
457 |
+
total_price: parseFloat(row.querySelector("input[name='totalHT']").value)
|
458 |
+
})));
|
459 |
+
});
|
460 |
+
return items;
|
461 |
+
};
|
462 |
+
|
463 |
+
const data = {
|
464 |
+
invoice_number: document.querySelector("#invoiceNumber").value,
|
465 |
+
date: new Date(document.querySelector("#date").value).toISOString(),
|
466 |
+
project: document.querySelector("#project").value,
|
467 |
+
client_name: document.querySelector("#clientName").value,
|
468 |
+
client_phone: document.querySelector("#clientPhone").value,
|
469 |
+
address: document.querySelector("#clientAddress").value,
|
470 |
+
total_ht: parseFloat(document.querySelector("#totalHT").value || 0),
|
471 |
+
tax: parseFloat(document.querySelector("#tax").value || 0),
|
472 |
+
total_ttc: parseFloat(document.querySelector("#totalTTC").value || 0),
|
473 |
+
items: getAllItems(),
|
474 |
+
frame_number: "",
|
475 |
+
status: "pending",
|
476 |
+
created_at: new Date().toISOString()
|
477 |
+
};
|
478 |
+
|
479 |
+
console.log("Sending invoice data:", data);
|
480 |
+
|
481 |
+
try {
|
482 |
+
// First create the invoice
|
483 |
+
const createResponse = await fetch("/api/invoices/", {
|
484 |
+
method: "POST",
|
485 |
+
headers: { "Content-Type": "application/json" },
|
486 |
+
body: JSON.stringify(data)
|
487 |
+
});
|
488 |
+
|
489 |
+
if (!createResponse.ok) {
|
490 |
+
const errorData = await createResponse.json();
|
491 |
+
console.error("Server error:", errorData);
|
492 |
+
throw new Error(`Failed to create invoice: ${JSON.stringify(errorData)}`);
|
493 |
+
}
|
494 |
+
|
495 |
+
const invoice = await createResponse.json();
|
496 |
+
console.log("Created invoice:", invoice);
|
497 |
+
|
498 |
+
// Then generate the PDF
|
499 |
+
const pdfResponse = await fetch(`/api/invoices/${invoice.id}/generate-pdf`, {
|
500 |
+
method: "POST",
|
501 |
+
headers: {
|
502 |
+
"Content-Type": "application/json"
|
503 |
+
},
|
504 |
+
body: JSON.stringify(invoice) // Send the created invoice data for PDF generation
|
505 |
+
});
|
506 |
+
|
507 |
+
if (!pdfResponse.ok) {
|
508 |
+
const errorData = await pdfResponse.text();
|
509 |
+
console.error("PDF generation error:", errorData);
|
510 |
+
throw new Error(`Failed to generate PDF: ${errorData}`);
|
511 |
+
}
|
512 |
+
|
513 |
+
// Handle PDF download
|
514 |
+
const blob = await pdfResponse.blob();
|
515 |
+
const url = window.URL.createObjectURL(blob);
|
516 |
+
const a = document.createElement("a");
|
517 |
+
a.href = url;
|
518 |
+
a.download = `devis_${invoice.invoice_number}.pdf`;
|
519 |
+
document.body.appendChild(a);
|
520 |
+
a.click();
|
521 |
+
document.body.removeChild(a);
|
522 |
+
window.URL.revokeObjectURL(url);
|
523 |
+
|
524 |
+
} catch (error) {
|
525 |
+
console.error("Error:", error);
|
526 |
+
alert(`Error: ${error.message}`);
|
527 |
+
}
|
528 |
+
|
529 |
+
// After successful submission, update the invoice number for the current type
|
530 |
+
const selectedType = document.querySelector('input[name="clientType"]:checked')?.value;
|
531 |
+
if (selectedType) {
|
532 |
+
await updateInvoiceNumber(selectedType);
|
533 |
+
}
|
534 |
+
});
|
535 |
+
|
536 |
+
// Set today's date automatically
|
537 |
+
const today = new Date();
|
538 |
+
const formattedDate = today.toISOString().split('T')[0];
|
539 |
+
document.querySelector("#date").value = formattedDate;
|
540 |
+
|
541 |
+
// Auto-generate invoice number when client type is selected
|
542 |
+
const clientTypeInputs = document.querySelectorAll('input[name="clientType"]');
|
543 |
+
const invoiceNumberInput = document.querySelector("#invoiceNumber");
|
544 |
+
|
545 |
+
// Function to get and update the invoice number
|
546 |
+
async function updateInvoiceNumber(selectedType) {
|
547 |
+
try {
|
548 |
+
// Disable the input while fetching
|
549 |
+
invoiceNumberInput.disabled = true;
|
550 |
+
|
551 |
+
const response = await fetch(`/api/invoices/last-number/${selectedType}`);
|
552 |
+
if (response.ok) {
|
553 |
+
const data = await response.json();
|
554 |
+
invoiceNumberInput.value = data.formatted_number;
|
555 |
+
} else {
|
556 |
+
throw new Error('Failed to get invoice number');
|
557 |
+
}
|
558 |
+
} catch (error) {
|
559 |
+
console.error('Error:', error);
|
560 |
+
invoiceNumberInput.value = `DCP/${selectedType}/0001`;
|
561 |
+
} finally {
|
562 |
+
// Re-enable the input
|
563 |
+
invoiceNumberInput.disabled = false;
|
564 |
+
}
|
565 |
+
}
|
566 |
+
|
567 |
+
// Update invoice number when client type is selected
|
568 |
+
clientTypeInputs.forEach(input => {
|
569 |
+
input.addEventListener('change', () => {
|
570 |
+
updateInvoiceNumber(input.value);
|
571 |
+
});
|
572 |
+
});
|
573 |
+
|
574 |
+
// Optional: Update the number when the page loads if a type is already selected
|
575 |
+
const selectedType = document.querySelector('input[name="clientType"]:checked')?.value;
|
576 |
+
if (selectedType) {
|
577 |
+
updateInvoiceNumber(selectedType);
|
578 |
+
}
|
579 |
+
});
|
580 |
+
</script>
|
581 |
+
</body>
|
582 |
+
</html>
|
migrations/versions/xxxx_add_invoice_sequence.py
ADDED
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
"""add invoice sequence and client type
|
2 |
+
|
3 |
+
Revision ID: xxxx
|
4 |
+
Revises: previous_revision_id
|
5 |
+
Create Date: 2024-xx-xx
|
6 |
+
"""
|
7 |
+
from alembic import op
|
8 |
+
import sqlalchemy as sa
|
9 |
+
|
10 |
+
def upgrade():
|
11 |
+
# Create sequence for invoice IDs
|
12 |
+
op.execute('CREATE SEQUENCE IF NOT EXISTS invoice_id_seq START 1')
|
13 |
+
|
14 |
+
# Add client_type column if not exists
|
15 |
+
op.add_column('invoices', sa.Column('client_type', sa.String(10), nullable=True))
|
16 |
+
|
17 |
+
# Make invoice_number unique
|
18 |
+
op.create_unique_constraint('uq_invoice_number', 'invoices', ['invoice_number'])
|
19 |
+
|
20 |
+
def downgrade():
|
21 |
+
# Remove unique constraint
|
22 |
+
op.drop_constraint('uq_invoice_number', 'invoices')
|
23 |
+
|
24 |
+
# Drop client_type column
|
25 |
+
op.drop_column('invoices', 'client_type')
|
26 |
+
|
27 |
+
# Drop sequence
|
28 |
+
op.execute('DROP SEQUENCE IF EXISTS invoice_id_seq')
|
requirements.txt
ADDED
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
fastapi==0.109.0
|
2 |
+
uvicorn==0.27.0
|
3 |
+
sqlalchemy==2.0.25
|
4 |
+
aiosqlite==0.19.0
|
5 |
+
pydantic==2.5.3
|
6 |
+
pydantic-settings==2.1.0
|
7 |
+
jinja2==3.1.3
|
8 |
+
python-multipart==0.0.6
|
9 |
+
reportlab==4.0.8
|
10 |
+
python-jose==3.3.0
|
11 |
+
passlib==1.7.4
|
12 |
+
alembic==1.13.1
|
sql_app.db
ADDED
Binary file (28.7 kB). View file
|
|
tests/__init__.py
ADDED
File without changes
|
tests/test_api.py
ADDED
File without changes
|
tests/test_services.py
ADDED
File without changes
|