Project Sync

This commit is contained in:
Argiris Deligiannidis 2024-02-02 15:58:35 +02:00
parent ddc10c25a0
commit d39e8d727b
15 changed files with 747 additions and 1 deletions

29
.drone.yml Normal file
View File

@ -0,0 +1,29 @@
kind: pipeline
name: default
steps:
- name: container-build
image: 'docker:dind'
environment:
USERNAME:
from_secret: docker_username
PASSWORD:
from_secret: docker_password
volumes:
- name: dockersock
path: /var/run/docker.sock
commands:
- >-
if [ "${DRONE_BRANCH}" = "main" ]; then export
BUILD_TAG="latest"; else export BUILD_TAG="${DRONE_BRANCH}"; fi;
- docker login -u $USERNAME -p $PASSWORD git.argideli.com
- >-
docker build -t
git.argideli.com/${DRONE_REPO_NAMESPACE}/${DRONE_REPO_NAME}:$BUILD_TAG
.
- >-
docker push
git.argideli.com/${DRONE_REPO_NAMESPACE}/${DRONE_REPO_NAME}:$BUILD_TAG
volumes:
- name: dockersock
host:
path: /var/run/docker.sock

160
.gitignore vendored Normal file
View File

@ -0,0 +1,160 @@
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
#pdm.lock
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
# in version control.
# https://pdm.fming.dev/#use-with-ide
.pdm.toml
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/

25
Dockerfile Normal file
View File

@ -0,0 +1,25 @@
# Use the official Python base image
FROM python:3.11-slim
# Set the working directory inside the container
WORKDIR /code
# Copy the requirements file to the working directory
COPY ./requirements.txt /code/requirements.txt
# Install the Python dependencies
RUN pip config --user set global.progress_bar off
RUN pip install --no-cache-dir --upgrade -r /code/requirements.txt
# Copy the application code to the working directory
COPY ./app /code/app
COPY ./app/dot_env /code/app/.env
COPY ./app/favicon.ico /code/favicon.ico
COPY ./app/favicon.ico /code/app/favicon.ico
# Expose the port on which the application will run
EXPOSE 80
# Run the FastAPI application using uvicorn server
#CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "80"]
CMD ["uvicorn", "app.main:app", "--proxy-headers", "--host", "0.0.0.0", "--port", "80"]

View File

@ -1,2 +1,45 @@
# qr-api
# Python QR Codes API
Python API for adding QR Codes in invoices
# Deployment
docker-compose up
The signed invoices are saved at the invoices folder, their filenames are inv_code.pdf
# Example Calls
## Sign with QR
<ins>HTTP-POST</ins>
https://qrcode.argideli.com/signinvoice/?company=Quertex&inv_type=tpy&in_all=0&preset=inv2&inv_series=2&inv_num=5578
The call body is a multipart form-data, containing the actual invoice. Upon signing with the QR code the new file is returned
<ins>Parameters</ins>
* file: UploadFile = File(...) = File to be signed
* preset: str = Invoice Preset Selection
* code_content: str = QR code Data, if empty the code data that will be put inside the QR will be the url to download the invoice
* company: str = Invoice type, english letters (ex. Quertex), NOTE: The Upper Case letter WILL be carried to the filename
* inv_type: str = Invoice type, english letters (ex. tpy)
* inv_series: str = Invoice series, english letters (ex. 2 or B1)
* inv_num: str = Invoice Number
* in_all: bool = Sign all pages (0|1)
## Get invoice
<ins>HTTP-GET</ins>
https://qrcode.argideli.com/getinvoice/?inv_code=Quertex_tpy_2_5578
Get a saved invoice by calling an HTTP/GET and providing the filename as the inv_code parameter, alternatevily the identifier can be reconstructed from the company, inv_type, inv_series, inv_num parameters
<ins>Parameters</ins>
* inv_code: str = Invoice identifier/Filename without .pdf extention
* company: str = Invoice type, english letters (ex. Quertex), NOTE: The Upper Case letter WILL be carried to the filename
* inv_type: str = Invoice type, english letters (ex. tpy)
* inv_series: str = Invoice series, english letters (ex. 2 or B1)
* inv_num: str = Invoice Number
* search_file: bool = Get file by reconstructing the filename from (company, inv_type, inv_series, inv_num), (0|1)

160
app/.gitignore vendored Normal file
View File

@ -0,0 +1,160 @@
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
#pdm.lock
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
# in version control.
# https://pdm.fming.dev/#use-with-ide
.pdm.toml
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/

0
app/__init__.py Normal file
View File

7
app/dot_env Normal file
View File

@ -0,0 +1,7 @@
SERVICE_URL= "https://qrcode.argideli.com"
DESTINATION = "/var/invoices/"
#SERVICE_URL= "http://127.0.0.1:8000"
#DESTINATION = "../invoices/"
CHUNK_SIZE =1048576 # = 2 ** 20 = 1MB

BIN
app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

24
app/favicon.svg Normal file
View File

@ -0,0 +1,24 @@
<?xml version="1.0" encoding="iso-8859-1"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg fill="#000000" version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
width="800px" height="800px" viewBox="0 0 45.973 45.973"
xml:space="preserve">
<g>
<g>
<path d="M43.454,18.443h-2.437c-0.453-1.766-1.16-3.42-2.082-4.933l1.752-1.756c0.473-0.473,0.733-1.104,0.733-1.774
c0-0.669-0.262-1.301-0.733-1.773l-2.92-2.917c-0.947-0.948-2.602-0.947-3.545-0.001l-1.826,1.815
C30.9,6.232,29.296,5.56,27.529,5.128V2.52c0-1.383-1.105-2.52-2.488-2.52h-4.128c-1.383,0-2.471,1.137-2.471,2.52v2.607
c-1.766,0.431-3.38,1.104-4.878,1.977l-1.825-1.815c-0.946-0.948-2.602-0.947-3.551-0.001L5.27,8.205
C4.802,8.672,4.535,9.318,4.535,9.978c0,0.669,0.259,1.299,0.733,1.772l1.752,1.76c-0.921,1.513-1.629,3.167-2.081,4.933H2.501
C1.117,18.443,0,19.555,0,20.935v4.125c0,1.384,1.117,2.471,2.501,2.471h2.438c0.452,1.766,1.159,3.43,2.079,4.943l-1.752,1.763
c-0.474,0.473-0.734,1.106-0.734,1.776s0.261,1.303,0.734,1.776l2.92,2.919c0.474,0.473,1.103,0.733,1.772,0.733
s1.299-0.261,1.773-0.733l1.833-1.816c1.498,0.873,3.112,1.545,4.878,1.978v2.604c0,1.383,1.088,2.498,2.471,2.498h4.128
c1.383,0,2.488-1.115,2.488-2.498v-2.605c1.767-0.432,3.371-1.104,4.869-1.977l1.817,1.812c0.474,0.475,1.104,0.735,1.775,0.735
c0.67,0,1.301-0.261,1.774-0.733l2.92-2.917c0.473-0.472,0.732-1.103,0.734-1.772c0-0.67-0.262-1.299-0.734-1.773l-1.75-1.77
c0.92-1.514,1.627-3.179,2.08-4.943h2.438c1.383,0,2.52-1.087,2.52-2.471v-4.125C45.973,19.555,44.837,18.443,43.454,18.443z
M22.976,30.85c-4.378,0-7.928-3.517-7.928-7.852c0-4.338,3.55-7.85,7.928-7.85c4.379,0,7.931,3.512,7.931,7.85
C30.906,27.334,27.355,30.85,22.976,30.85z"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

226
app/main.py Normal file
View File

@ -0,0 +1,226 @@
import os
import io
import fitz
import qrcode
import datetime
import logging
import urllib.parse
from typing import Optional
from dotenv import load_dotenv
from fastapi.responses import FileResponse
from fastapi import FastAPI, File, UploadFile, Response
load_dotenv()
SERVICE_URL = os.getenv('SERVICE_URL')
DESTINATION = os.getenv('DESTINATION')
CHUNK_SIZE = int(os.getenv('CHUNK_SIZE'))
log = logging.getLogger(__name__)
app = FastAPI()
def urlencode(str):
return urllib.parse.quote(str)
def urldecode(str):
return urllib.parse.unquote(str)
def make_qrcode(qr_data):
"""
Generate a QR code from the given qr_data.
Args:
qr_data (str): The data to be encoded in the QR code.
Returns:
io.BytesIO: The QR code image as a BytesIO object.
"""
qr = qrcode.QRCode(
version=1,
error_correction=qrcode.constants.ERROR_CORRECT_L,
box_size=6,
border=2,
)
qr.add_data(qr_data)
qr.make(fit=True)
img = qr.make_image(fill_color="black", back_color="white")
fp = io.BytesIO()
img.save(fp, "PNG")
return fp
def create_qr_rectangle(preset, page):
"""
Create a QR rectangle based on the preset and page provided.
Parameters:
- preset (str): The preset for the QR rectangle.
- page (Page): The page on which the QR rectangle will be created.
Returns:
- rect (Rect): The created QR rectangle.
"""
w = page.rect.width # page width
if preset == "inv1":
margin = 15
left = w*0.40
elif preset == "inv2":
margin = 20
left = w*0.88
else:
margin = 0
left = 0
rect = fitz.Rect(left, margin, left + 65, margin + 65)
return rect
async def chunked_copy(src, dst):
"""
Asynchronously copies the contents of the source file to the destination file
in chunks.
Args:
src: The source file object.
dst: The destination file path.
Returns:
None
"""
await src.seek(0)
with open(dst, "wb") as buffer:
while True:
contents = await src.read(CHUNK_SIZE)
if not contents:
log.info(f"Src completely consumed\n")
break
log.info(f"Consumed {len(contents)} bytes from Src file\n")
buffer.write(contents)
@app.post("/signinvoice/")
async def sign_invoice(
file: UploadFile = File(...),
preset: Optional[str] = None,
code_content: Optional[str] = None,
company: Optional[str] = None,
inv_type: Optional[str] = None,
inv_series: Optional[str] = None,
inv_num: Optional[str] = None,
in_all: bool = False
):
"""
Sign an invoice file and generate a QR code for it.
Args:
file (UploadFile): The file to be signed.
preset (str, optional): The preset for the QR code.
code_content (str, optional): The content for the QR code.
company (str, optional): The company name.
inv_type (str, optional): The invoice type.
inv_series (str, optional): The invoice series.
inv_num (str, optional): The invoice number.
in_all (bool): Whether to include the QR code in all pages.
Returns:
FileResponse: The signed invoice file with QR code.
"""
inv_code = "{}_{}_{}_{}".format(company, inv_type, inv_series, inv_num).replace(" ", "_").replace(",", "").replace("#", "")
inv_url = "{}/getinvoice/?inv_code={}".format(SERVICE_URL, inv_code)
#print("SIGNED URL: {}".format(inv_url))
temp_path = os.path.join(DESTINATION, f"{inv_code}_temp.pdf")
full_path = os.path.join(DESTINATION, f"{inv_code}.pdf")
await chunked_copy(file, temp_path)
if not code_content:
code_content = inv_url
qr_image = make_qrcode(code_content)
doc = fitz.open(temp_path)
add_qr = True
for page in doc:
if add_qr:
rect = create_qr_rectangle(preset, page)
if not page.is_wrapped:
page.wrap_contents()
page.insert_image(rect, stream=qr_image)
if not in_all and page.number == 0:
add_qr = False
doc.save(full_path, deflate=True, garbage=3)
if os.path.isfile(temp_path):
os.remove(temp_path)
print("{} : Returned Signed Invoice".format(datetime.datetime.now()))
return FileResponse(full_path)
@app.get("/getinvoice/")
async def get_invoice(
company: Optional[str] = None,
inv_code: Optional[str] = None,
inv_type: Optional[str] = None,
inv_series: Optional[str] = None,
inv_num: Optional[str] = None,
search_file: bool = False
):
"""
An asynchronous function to retrieve an invoice file based on various parameters.
Args:
company (Optional[str]): The company name.
inv_code (Optional[str]): The invoice code.
inv_type (Optional[str]): The invoice type.
inv_series (Optional[str]): The invoice series.
inv_num (Optional[str]): The invoice number.
search_file (bool): Flag to indicate whether to search for the file.
Returns:
Union[FileResponse, Dict[str, str]]: Returns the invoice file if found, otherwise returns a message.
"""
if search_file:
inv_code = "{}_{}_{}_{}".format(company, inv_type, inv_series, inv_num).replace(" ", "_").replace(",", "").replace("#", "")
if inv_code:
full_path = os.path.join(DESTINATION, "{}.pdf".format(inv_code))
if os.path.isfile(full_path):
print("FETCHING INVOICE: {} | {}".format(inv_code, full_path))
return FileResponse(full_path)
else:
print("{} : No invoice found".format(datetime.datetime.now()))
return {"message": "No invoice found"}
@app.get("/createqr/")
async def create_qr_code(code_content: Optional[str] = None, token: Optional[str] = None):
"""
Create a QR code from the given code_content.
Args:
code_content (str, optional): The content to be encoded in the QR code. Defaults to None.
Returns:
Response: The QR code image as a Response object with media type "image/png".
"""
if token == 'ZPOclCF5od59SgW6PLM2':
if code_content:
qr_image = make_qrcode(code_content)
print("{} : QR Created".format(datetime.datetime.now()))
return Response(content=qr_image.getvalue(), media_type="image/png")
else:
print("{} : QR Code Content not provided".format(datetime.datetime.now()))
return "QR Code Content not provided"
else:
return "Unauthorized"
@app.get('/favicon.ico', include_in_schema=False)
async def favicon():
return FileResponse("favicon.ico")

15
docker-compose-local.yml Normal file
View File

@ -0,0 +1,15 @@
version: '3.8'
services:
qrcode-api:
container_name: qrcode-api
image: localhost:7776/qrcodes:latest
restart: always
volumes:
- ./invoices:/var/invoices
ports:
- 8188:80
networks:
qrcode-net:
driver: bridge

32
docker-compose.yml Normal file
View File

@ -0,0 +1,32 @@
version: '3.5'
services:
qrcode-api:
container_name: qrcode-api
image: git.argideli.com/quertex/qr-api:latest
restart: always
volumes:
- ./invoices:/var/invoices
networks:
- qrcode-net
ports:
- 8188:80
labels:
- "com.centurylinklabs.watchtower.scope=qrcode-api-master"
qrcode-api-watchtower:
container_name: qrcode-api-watchtower
image: containrrr/watchtower
networks:
- qrcode-net
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- ./config.json:/config.json
command: --interval 300 --scope api
labels:
- "com.centurylinklabs.watchtower.scope=qrcode-api-master"
networks:
qrcode-net:
name: qrcode-net
driver: bridge

1
invoices/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
*.pdf

18
requirements.txt Normal file
View File

@ -0,0 +1,18 @@
annotated-types==0.6.0
anyio==4.2.0
click==8.1.7
fastapi==0.109.0
h11==0.14.0
idna==3.6
pydantic==2.5.3
pydantic_core==2.14.6
PyMuPDF==1.23.13
PyMuPDFb==1.23.9
pypng==0.20220715.0
python-dotenv==1.0.0
python-multipart==0.0.6
qrcode==7.4.2
sniffio==1.3.0
starlette==0.35.1
typing_extensions==4.9.0
uvicorn==0.25.0

6
start_dev_server.sh Normal file
View File

@ -0,0 +1,6 @@
#/bin/bash
source ./venv/bin/activate
cd app
uvicorn main:app --reload
cd ..
deactivate