From d39e8d727b18aeb9a7481507f4e17499e3d847d9 Mon Sep 17 00:00:00 2001 From: Argiris Deligiannidis Date: Fri, 2 Feb 2024 15:58:35 +0200 Subject: [PATCH] Project Sync --- .drone.yml | 29 +++++ .gitignore | 160 +++++++++++++++++++++++++++ Dockerfile | 25 +++++ README.md | 45 +++++++- app/.gitignore | 160 +++++++++++++++++++++++++++ app/__init__.py | 0 app/dot_env | 7 ++ app/favicon.ico | Bin 0 -> 4286 bytes app/favicon.svg | 24 +++++ app/main.py | 226 +++++++++++++++++++++++++++++++++++++++ docker-compose-local.yml | 15 +++ docker-compose.yml | 32 ++++++ invoices/.gitignore | 1 + requirements.txt | 18 ++++ start_dev_server.sh | 6 ++ 15 files changed, 747 insertions(+), 1 deletion(-) create mode 100644 .drone.yml create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 app/.gitignore create mode 100644 app/__init__.py create mode 100644 app/dot_env create mode 100644 app/favicon.ico create mode 100644 app/favicon.svg create mode 100644 app/main.py create mode 100644 docker-compose-local.yml create mode 100644 docker-compose.yml create mode 100644 invoices/.gitignore create mode 100644 requirements.txt create mode 100644 start_dev_server.sh diff --git a/.drone.yml b/.drone.yml new file mode 100644 index 0000000..5b14c9b --- /dev/null +++ b/.drone.yml @@ -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 \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..68bc17f --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..a780005 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md index 77b917e..08e1ab6 100644 --- a/README.md +++ b/README.md @@ -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 +HTTP-POST + +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 + +Parameters + +* 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 +HTTP-GET + +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 + +Parameters + +* 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) diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 0000000..68bc17f --- /dev/null +++ b/app/.gitignore @@ -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/ diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/dot_env b/app/dot_env new file mode 100644 index 0000000..07811f4 --- /dev/null +++ b/app/dot_env @@ -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 diff --git a/app/favicon.ico b/app/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..1acc13744ec6a7e92174398529198e286df1d79f GIT binary patch literal 4286 zcmcgu%T5$Q6s?Jgqb?kA!A`p)sJL(;Cc01(WF_zue1i+u!%h6$U)#+WGpE;9?z zU$eah-#?%z!Tu^R3cC#v(betb2p-^JK%cQX>@i4ofGr?%2*=QW3Fvvtn6c|lK5NO* z0aBms33C4c!fDZ;13m(WfcSkabC~-Kuz=(ppwD@QjB!_6YU^tG4AX%J;5)BN%g6I8 zpwHjRS67$+TR=ZU%ntDx$W4uqEh1RgTs6;pNVtsg{pdP9=6T28>RpNN2{q*S41_F4 zP;2Qi*XW%JlDqKjCe+`osO6c;^I;`Y-$DEm;IqpJ#P-k|=qJEfGQ5{<2OtDeB-s4uoM51$m@PXeY)arJN&Wkt3-X_PHC58qE7tvIrLNC!MejA z>wZntr*`TZI7V&`#Ek3u6i9iea%W;q%Kqct;vVza6$E|*A#J_?T&_G)OJ1Hayk2U% z2ge=|(q=5vg=)N!z(Jm$!21&0i8ZnF7c^!ekt;UOs`zsX1dQ*-0#&tj_{dESpN-=F zvFQ_szXQ}U&kzUKiqCjA>;j?s|B3B``@T=S^glxDK7+-02KWH{1!DTlVeV7F0+LVd z(A^i|fjvNv`-^v>Y~VHKD*7(~;nXc-%n@TPIeq}Kc}q>K2_k+ z;w3-tOzwA6zGLT>FM3%~bX$G&mM&&kcr9y8(*=Ef|AkJo-L#GVDC=QmI6wwt7CD3b L3La{R9<|Q^zb + + + + + + + + + \ No newline at end of file diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..2349f22 --- /dev/null +++ b/app/main.py @@ -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") diff --git a/docker-compose-local.yml b/docker-compose-local.yml new file mode 100644 index 0000000..b2ea323 --- /dev/null +++ b/docker-compose-local.yml @@ -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 + diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..a8a93bb --- /dev/null +++ b/docker-compose.yml @@ -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 + diff --git a/invoices/.gitignore b/invoices/.gitignore new file mode 100644 index 0000000..f08278d --- /dev/null +++ b/invoices/.gitignore @@ -0,0 +1 @@ +*.pdf \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..5814fa7 --- /dev/null +++ b/requirements.txt @@ -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 diff --git a/start_dev_server.sh b/start_dev_server.sh new file mode 100644 index 0000000..46dfb5b --- /dev/null +++ b/start_dev_server.sh @@ -0,0 +1,6 @@ +#/bin/bash +source ./venv/bin/activate +cd app +uvicorn main:app --reload +cd .. +deactivate