services api

This commit is contained in:
Berkay 2025-07-31 17:26:30 +03:00
parent 479104a04f
commit 1f8db23f75
56 changed files with 1976 additions and 120 deletions

2
.gitignore vendored
View File

@ -164,4 +164,4 @@ yarn-error.log*
next-env.d.ts
# Project specific
ServicesBank/Zemberek/
trash/Zemberek/

View File

@ -386,7 +386,7 @@ class LoginHandler:
).join(BuildParts, BuildParts.id == BuildLivingSpace.build_parts_id
).join(Build, Build.id == BuildParts.build_id
).join(People, People.id == BuildLivingSpace.person_id
).filter(BuildLivingSpace.uu_id == data.uuid)
).filter(BuildLivingSpace.uu_id == data.uuid)
selected_build_living_space_first = selected_build_living_space_query.first()
if not selected_build_living_space_first:

View File

@ -17,7 +17,7 @@ if __name__ == "__main__":
with get_db() as db_session:
if set_alembic:
generate_alembic(session=db_session)
exit()
try:
create_one_address(db_session=db_session)
except Exception as e:

View File

@ -26,7 +26,6 @@ class TokenProvider:
return OccupantTokenObject(**redis_object)
raise ValueError("Invalid user type")
@classmethod
def get_login_token_from_redis(
cls, token: Optional[str] = None, user_uu_id: Optional[str] = None

View File

@ -7,6 +7,8 @@ from Schemas.account.account import (
AccountRecordExchanges,
AccountRecords,
AccountDelayInterest,
AccountRecordsPredict,
AccountRecordsModelTrain,
)
from Schemas.account.iban import (
BuildIbans,
@ -126,6 +128,8 @@ __all__ = [
"AccountRecordExchanges",
"AccountRecords",
"AccountDelayInterest",
"AccountRecordsPredict",
"AccountRecordsModelTrain",
"BuildIbans",
"BuildIbanDescription",
"RelationshipEmployee2PostCode",

View File

@ -1,6 +1,7 @@
from Schemas.base_imports import (
CrudCollection,
String,
Text,
Integer,
Boolean,
ForeignKey,
@ -384,9 +385,9 @@ class AccountRecordsPredict(CrudCollection):
account_records_id: Mapped[int] = mapped_column(ForeignKey("account_records.id"), nullable=False)
account_records_uu_id: Mapped[str] = mapped_column(String(100), nullable=False)
prediction_model: Mapped[str] = mapped_column(String(10), nullable=False)
prediction_result: Mapped[int] = mapped_column(Integer, nullable=False)
prediction_field: Mapped[str] = mapped_column(String(10), nullable=False, server_default="")
prediction_model: Mapped[str] = mapped_column(String, nullable=False)
prediction_result: Mapped[str] = mapped_column(Text, nullable=False)
prediction_field: Mapped[str] = mapped_column(String, nullable=False, server_default="")
treshold: Mapped[float] = mapped_column(Numeric(18, 6), nullable=True)
is_first_prediction: Mapped[bool] = mapped_column(Boolean, server_default="0")
is_approved: Mapped[bool] = mapped_column(Boolean, server_default="0")

View File

@ -211,49 +211,22 @@ class People(CrudCollection):
"tax_no",
]
firstname: Mapped[str] = mapped_column(
String, nullable=False, comment="First name of the person"
)
surname: Mapped[str] = mapped_column(
String(24), nullable=False, comment="Surname of the person"
)
middle_name: Mapped[str] = mapped_column(
String, server_default="", comment="Middle name of the person"
)
sex_code: Mapped[str] = mapped_column(
String(1), nullable=False, comment="Sex code of the person (e.g., M/F)"
)
person_ref: Mapped[str] = mapped_column(
String, server_default="", comment="Reference ID for the person"
)
person_tag: Mapped[str] = mapped_column(
String, server_default="", comment="Unique tag for the person"
)
firstname: Mapped[str] = mapped_column(String, nullable=False, comment="First name of the person")
surname: Mapped[str] = mapped_column(String(24), nullable=False, comment="Surname of the person")
middle_name: Mapped[str] = mapped_column(String, server_default="", comment="Middle name of the person")
birthname: Mapped[str] = mapped_column(String, nullable=True, server_default="", comment="Birth name of the person")
sex_code: Mapped[str] = mapped_column(String(1), nullable=False, comment="Sex code of the person (e.g., M/F)")
person_ref: Mapped[str] = mapped_column(String, server_default="", comment="Reference ID for the person")
person_tag: Mapped[str] = mapped_column(String, server_default="", comment="Unique tag for the person")
# ENCRYPT DATA
father_name: Mapped[str] = mapped_column(
String, server_default="", comment="Father's name of the person"
)
mother_name: Mapped[str] = mapped_column(
String, server_default="", comment="Mother's name of the person"
)
country_code: Mapped[str] = mapped_column(
String(4), server_default="TR", comment="Country code of the person"
)
national_identity_id: Mapped[str] = mapped_column(
String, server_default="", comment="National identity ID of the person"
)
birth_place: Mapped[str] = mapped_column(
String, server_default="", comment="Birth place of the person"
)
birth_date: Mapped[TIMESTAMP] = mapped_column(
TIMESTAMP(timezone=True),
server_default="1900-01-01",
comment="Birth date of the person",
)
tax_no: Mapped[str] = mapped_column(
String, server_default="", comment="Tax number of the person"
)
father_name: Mapped[str] = mapped_column(String, server_default="", comment="Father's name of the person")
mother_name: Mapped[str] = mapped_column(String, server_default="", comment="Mother's name of the person")
country_code: Mapped[str] = mapped_column(String(4), server_default="TR", comment="Country code of the person")
national_identity_id: Mapped[str] = mapped_column(String, server_default="", comment="National identity ID of the person")
birth_place: Mapped[str] = mapped_column(String, server_default="", comment="Birth place of the person")
birth_date: Mapped[TIMESTAMP] = mapped_column(TIMESTAMP(timezone=True), server_default="1900-01-01", comment="Birth date of the person")
tax_no: Mapped[str] = mapped_column(String, server_default="", comment="Tax number of the person")
# Receive at Create person
# language = mapped_column(
# String, comment="Language code of the person"
@ -262,10 +235,7 @@ class People(CrudCollection):
# String, comment="Currency code of the person"
# )
# ENCRYPT DATA
user = relationship(
"Users", back_populates="person", foreign_keys="Users.person_id"
)
user = relationship("Users", back_populates="person", foreign_keys="Users.person_id")
__table_args__ = (
Index(

View File

@ -0,0 +1,31 @@
import os
class Config:
MAILBOX: str = os.getenv("MAILBOX", "bilgilendirme@ileti.isbank.com.tr")
MAIN_MAIL: str = os.getenv("MAIN_MAIL", "karatay.berkay@gmail.com")
INFO_MAIL: str = os.getenv("INFO_MAIL", "mehmet.karatay@hotmail.com")
EMAIL_HOST: str = os.getenv("EMAIL_HOST", "10.10.2.34")
EMAIL_SENDER_USERNAME: str = os.getenv("EMAIL_SENDER_USERNAME", "karatay@mehmetkaratay.com.tr")
EMAIL_USERNAME: str = os.getenv("EMAIL_USERNAME", "isbank@mehmetkaratay.com.tr")
EMAIL_PASSWORD: str = os.getenv("EMAIL_PASSWORD", "system")
AUTHORIZE_IBAN: str = os.getenv("AUTHORIZE_IBAN", "4245-0093333")
SERVICE_TIMING: int = int(os.getenv("SERVICE_TIMING", 900))
EMAIL_PORT: int = int(os.getenv("EMAIL_PORT", 993))
EMAIL_SEND_PORT: int = int(os.getenv("EMAIL_SEND_PORT", 587))
EMAIL_SLEEP: int = int(os.getenv("EMAIL_SLEEP", 60))
EMAIL_SEND: bool = bool(os.getenv("EMAIL_SEND", False))
class EmailConfig:
EMAIL_HOST: str = os.getenv("EMAIL_HOST", "10.10.2.34")
EMAIL_USERNAME: str = Config.EMAIL_SENDER_USERNAME
EMAIL_PASSWORD: str = Config.EMAIL_PASSWORD
EMAIL_PORT: int = Config.EMAIL_SEND_PORT
EMAIL_SEND: bool = Config.EMAIL_SEND
@classmethod
def as_dict(cls):
return dict(host=EmailConfig.EMAIL_HOST, port=EmailConfig.EMAIL_PORT, username=EmailConfig.EMAIL_USERNAME, password=EmailConfig.EMAIL_PASSWORD)

View File

@ -0,0 +1,54 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Gelen Banka Kayıtları</title>
<style>
body {
font-family: Arial, sans-serif;
margin: 20px;
}
table {
width: 100%;
border-collapse: collapse;
}
table, th, td {
border: 1px solid black;
}
th, td {
padding: 10px;
text-align: left;
}
th {
background-color: #f2f2f2;
}
</style>
</head>
<body>
<h1>Günaydın, Admin</h1>
<br>
<p>Banka Kayıtları : {{today}} </p>
<p><b>Son Bakiye : {{bank_balance}} </b></p>
<p><b>{{"Status : İkinci Bakiye Hatalı" if balance_error else "Status :OK"}}</b></p>
<table border="1">
<thead>
<tr>
{% for header in headers %}
<th>{{ header }}</th>
{% endfor %}
</tr>
</thead>
<tbody>
{% for row in rows %}
<tr>
{% for cell in row %}
<td>{{ cell }}</td>
{% endfor %}
</tr>
{% endfor %}
</tbody>
</table>
<p>Teşekkür ederiz,<br>Evyos Yönetim<br>Saygılarımızla</p>
</body>
</html>

View File

@ -0,0 +1,17 @@
FROM python:3.12-slim
WORKDIR /
RUN apt-get update && apt-get install -y --no-install-recommends gcc && rm -rf /var/lib/apt/lists/* && pip install --no-cache-dir poetry
COPY /ServicesBank/Email/pyproject.toml ./pyproject.toml
RUN poetry config virtualenvs.create false && poetry install --no-interaction --no-ansi --no-root --only main && pip cache purge && rm -rf ~/.cache/pypoetry
COPY /ServicesBank/Email /
COPY /ServicesApi/Controllers /ServicesApi/Controllers
COPY /ServicesBank/Depends/config.py /ServicesBank/Depends/config.py
ENV PYTHONPATH=/ PYTHONUNBUFFERED=1 PYTHONDONTWRITEBYTECODE=1
CMD ["poetry", "run", "python", "app.py"]

View File

@ -0,0 +1,84 @@
# Email Service
## Overview
The Email Service is the first component in a Redis pub/sub processing chain for bank-related email automation. It monitors a specified mailbox for emails with attachments, filters them based on IBAN criteria, and publishes the data to a Redis channel for further processing.
## Features
### Email Processing
- Connects to a configured mailbox using IMAP
- Implements smart date-based filtering:
- Checks emails from the past 14 days on the first run of each day
- Checks emails from the past 7 days on subsequent runs within the same day
- Extracts attachments from emails
- Filters attachments based on IBAN criteria
- Uses a context manager to ensure emails are properly handled even during errors
### Redis Integration
- Publishes messages to a Redis pub/sub channel ("CollectedData")
- Each message contains:
- Unique UUID
- Timestamp
- Initial stage marker ("red")
- Attachment payload and metadata
- Connects to an external Redis server
### Error Handling
- Robust error management with context managers
- Automatic marking of emails as unread if processing fails
- Comprehensive logging
## Configuration
### Environment Variables
```
EMAIL_HOST=10.10.2.34
EMAIL_USERNAME=isbank@mehmetkaratay.com.tr
EMAIL_PASSWORD=system
EMAIL_SLEEP=60
AUTHORIZE_IBAN=4245-0093333
REDIS_HOST=10.10.2.15
REDIS_PORT=6379
REDIS_PASSWORD=your_strong_password_here
```
## Deployment
### Docker
The service is containerized using Docker and can be deployed using the provided Dockerfile and docker-compose configuration.
```bash
# Build and start the service
docker compose -f bank-services-docker-compose.yml up -d --build
# View logs
docker compose -f bank-services-docker-compose.yml logs -f email_service
# Stop the service
docker compose -f bank-services-docker-compose.yml down
```
### Service Management
The `check_bank_services.sh` script provides a simple way to restart the service:
```bash
./check_bank_services.sh
```
## Architecture
### Redis Pub/Sub Chain
This service is the first in a multi-stage processing chain:
1. **Email Service** (this service): Reads emails, extracts attachments, publishes to Redis with stage="red"
2. **Processor Service**: Subscribes to stage="red" messages, processes data, republishes with stage="processed"
3. **Writer Service**: Subscribes to stage="processed" messages, writes data to final destination, marks as stage="completed"
## Development
### Dependencies
- Python 3.12
- Redbox (email library)
- Redis
### State Management
The service maintains a state file at `/tmp/email_service_last_run.json` to track when it last ran, enabling the smart date-based filtering feature.

169
ServicesBank/Email/app.py Normal file
View File

@ -0,0 +1,169 @@
import time
import arrow
import os
import json
import base64
from uuid import uuid4
from datetime import datetime, timedelta
from typing import TypeVar
from redbox import EmailBox
from redbox.query import FROM, UNSEEN, OR, SINCE
from ServicesApi.Controllers.Redis.Broadcast.actions import redis_pubsub
from ServicesBank.Depends.config import Config
authorized_iban = Config.AUTHORIZE_IBAN
authorized_iban_cleaned = authorized_iban.replace("-", "")
REDIS_CHANNEL = "reader"
delimiter = "|"
# banks_mails = mailbox.search(from_=filter_mail, unseen=True) bununla denemeyin
# banks_mails = mailbox.search(FROM(filter_mail) & UNSEEN)
T = TypeVar("T")
class EmailProcessingContext:
"""Context manager for email processing that marks emails as unread if an error occurs."""
def __init__(self, email_message, mark_as_read: bool = True):
self.email_message = email_message
self.mark_as_read = mark_as_read
self.success = False
def __enter__(self):
return self
def __exit__(self, exc_type, exc_val, exc_tb):
if exc_type is not None or not self.success:
try:
if hasattr(self.email_message, "mark_as_unread"):
self.email_message.mark_as_unread()
print(f"[EMAIL_SERVICE] Marked email as UNREAD due to processing error: {exc_val if exc_val else 'Unknown error'}")
except Exception as e:
print(f"[EMAIL_SERVICE] Failed to mark email as unread: {str(e)}")
elif self.mark_as_read:
try:
if hasattr(self.email_message, "mark_as_read"):
self.email_message.mark_as_read()
except Exception as e:
print(f"[EMAIL_SERVICE] Failed to mark email as read: {str(e)}")
return False
def publish_payload_to_redis(payload, filename: str, mail_info: dict) -> bool:
if isinstance(payload, bytes):
encoded_payload = base64.b64encode(payload).decode("utf-8")
is_base64 = True
else:
encoded_payload = payload
is_base64 = False
message = {
"filename": filename, "payload": encoded_payload, "is_base64": is_base64, "stage": "red",
"created_at": str(arrow.now()), "uuid": str(uuid4()), **mail_info,
}
result = redis_pubsub.publisher.publish(REDIS_CHANNEL, message)
if result.status:
print(f"[EMAIL_SERVICE] Published message with filename: {filename} to channel: {REDIS_CHANNEL}")
return True
else:
print(f"[EMAIL_SERVICE] Publish error: {result.error}")
return False
def read_email_and_publish_to_redis(email_message, mail_info: dict) -> bool:
if email_message.is_multipart(): # Check if email has multipart content
for part in email_message.walk(): # Each part can be an attachment
content_disposition = part.get("Content-Disposition")
if content_disposition and "attachment" in content_disposition:
if filename := part.get_filename():
is_iban_in_filename = authorized_iban_cleaned in str(filename)
if is_iban_in_filename:
if payload := part.get_payload(decode=True):
return publish_payload_to_redis(payload=payload, filename=filename, mail_info=mail_info)
else:
content_disposition = email_message.get("Content-Disposition")
if content_disposition and "attachment" in content_disposition:
if filename := email_message.get_filename():
is_iban_in_filename = authorized_iban_cleaned in str(filename)
if is_iban_in_filename:
payload = email_message.get_payload(decode=True)
return publish_payload_to_redis(payload=payload, filename=filename, mail_info=mail_info)
return False
def app():
host = Config.EMAIL_HOST
port = Config.EMAIL_PORT
username = Config.EMAIL_USERNAME
password = Config.EMAIL_PASSWORD
box = EmailBox(host=host, port=port, username=username, password=password)
if not box:
return Exception("Mailbox not found")
box.connect()
mail_folders = box.mailfolders
filter_mail = OR(FROM(Config.MAILBOX), FROM(Config.MAIN_MAIL))
filter_print = f"{Config.MAILBOX} & {Config.MAIN_MAIL}"
last_run_file = "/tmp/email_service_last_run.json"
current_date = datetime.now().strftime("%Y-%m-%d")
days_to_check, full_check = 7, 90
try:
if os.path.exists(last_run_file):
with open(last_run_file, "r") as f:
last_run_data = json.load(f)
last_run_date = last_run_data.get("last_run_date")
if last_run_date != current_date:
days_to_check = full_check
print(f"[EMAIL_SERVICE] First run of the day. Checking emails from the past {days_to_check} days")
else:
print(f"[EMAIL_SERVICE] Subsequent run today. Checking emails from the past {days_to_check} days")
else:
days_to_check = full_check
print(f"[EMAIL_SERVICE] First run detected. Checking emails from the past {days_to_check} days")
except Exception as e:
print(f"[EMAIL_SERVICE] Error reading last run file: {str(e)}. Using default of {days_to_check} days")
try:
with open(last_run_file, "w") as f:
json.dump({"last_run_date": current_date}, f)
except Exception as e:
print(f"[EMAIL_SERVICE] Error writing last run file: {str(e)}")
check_since_date = (datetime.now() - timedelta(days=days_to_check)).strftime("%d-%b-%Y")
for folder in mail_folders:
if folder.name == "INBOX":
banks_mails = folder.search(filter_mail & SINCE(check_since_date))
print(f"[EMAIL_SERVICE] Reading mailbox [{username}] with mail sender [{filter_print}] since {check_since_date} with count: {len(banks_mails)}")
for banks_mail in banks_mails or []:
if email_message := banks_mail.email:
with EmailProcessingContext(banks_mail) as ctx:
try:
headers = {k.lower(): v for k, v in banks_mail.headers.items()}
mail_info = {"from": headers["from"], "to": headers["to"], "subject": headers["subject"], "date": str(headers["date"])}
success = read_email_and_publish_to_redis(email_message=email_message, mail_info=mail_info)
ctx.success = success
if success:
print(f"[EMAIL_SERVICE] Successfully processed email with subject: {mail_info['subject']}")
else:
print(f"[EMAIL_SERVICE] No matching attachments found in email with subject: {mail_info['subject']}")
except Exception as e:
print(f"[EMAIL_SERVICE] Error processing email: {str(e)}")
if __name__ == "__main__":
print("=== Starting Email Service with Redis Pub/Sub ===")
print(f"Publishing to channel: {REDIS_CHANNEL}")
time.sleep(20)
while True:
print("\n[EMAIL_SERVICE] Checking for new emails...")
app()
time.sleep(Config.EMAIL_SLEEP)

View File

@ -0,0 +1,12 @@
[project]
name = "emailservice"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"arrow>=1.3.0",
"redbox>=0.2.1",
"redis>=5.2.1",
"pydantic-settings>=2.8.1",
]

View File

@ -0,0 +1,33 @@
FROM python:3.12-slim
WORKDIR /
# Set Python path to include app directory
ENV PYTHONPATH=/ PYTHONUNBUFFERED=1 PYTHONDONTWRITEBYTECODE=1
# Install system dependencies and Poetry
RUN apt-get update && apt-get install -y --no-install-recommends gcc && rm -rf /var/lib/apt/lists/* && pip install --no-cache-dir poetry
# Copy Poetry configuration
COPY /pyproject.toml ./pyproject.toml
# Configure Poetry and install dependencies with optimizations
RUN poetry config virtualenvs.create false && poetry install --no-interaction --no-ansi --no-root --only main && pip cache purge && rm -rf ~/.cache/pypoetry
# Install cron for scheduling tasks
RUN apt-get update && apt-get install -y cron
# Copy application code
COPY /ServicesBank/Finder/BuildExtractor /
COPY /ServicesApi/Schemas /Schemas
COPY /ServicesApi/Controllers /Controllers
# Create log file to grab cron logs
RUN touch /var/log/cron.log
# Make entrypoint script executable
RUN chmod +x /entrypoint.sh
RUN chmod +x /run_app.sh
# Use entrypoint script to update run_app.sh with environment variables and start cron
ENTRYPOINT ["/entrypoint.sh"]

View File

@ -0,0 +1,3 @@
# Docs of Build Extractor
Finds build_id, decision_book_id, living_space_id from AccountRecords

View File

@ -0,0 +1,66 @@
import arrow
from Schemas import AccountRecords, BuildIbans, BuildDecisionBook
from Controllers.Postgres.engine import get_session_factory
from sqlalchemy import cast, Date
def account_records_find_decision_book(session):
AccountRecords.set_session(session)
BuildIbans.set_session(session)
BuildDecisionBook.set_session(session)
created_ibans, iban_build_dict = [], {}
filter_account_records = AccountRecords.build_id != None, AccountRecords.build_decision_book_id == None
account_records_list: list[AccountRecords] = AccountRecords.query.filter(*filter_account_records).order_by(AccountRecords.bank_date.desc()).all()
for account_record in account_records_list:
if found_iban := BuildIbans.query.filter(BuildIbans.iban == account_record.iban).first():
if found_decision_book := BuildDecisionBook.query.filter(
BuildDecisionBook.build_id == found_iban.build_id,
cast(BuildDecisionBook.expiry_starts, Date) <= cast(account_record.bank_date, Date),
cast(BuildDecisionBook.expiry_ends, Date) >= cast(account_record.bank_date, Date),
).first():
account_record.build_decision_book_id = found_decision_book.id
account_record.build_decision_book_uu_id = str(found_decision_book.uu_id)
account_record.save()
def account_find_build_from_iban(session):
AccountRecords.set_session(session)
BuildIbans.set_session(session)
account_records_ibans = AccountRecords.query.filter(AccountRecords.build_id == None, AccountRecords.approved_record == False).distinct(AccountRecords.iban).all()
for account_records_iban in account_records_ibans:
found_iban: BuildIbans = BuildIbans.query.filter(BuildIbans.iban == account_records_iban.iban).first()
if not found_iban:
create_build_ibans = BuildIbans.create(iban=account_records_iban.iban, start_date=str(arrow.now().shift(days=-1)))
create_build_ibans.save()
else:
update_dict = {"build_id": found_iban.build_id, "build_uu_id": str(found_iban.build_uu_id)}
session.query(AccountRecords).filter(AccountRecords.iban == account_records_iban.iban).update(update_dict, synchronize_session=False)
session.commit()
if __name__ == "__main__":
print("Build Extractor Service is running...")
session_factory = get_session_factory()
session = session_factory()
try:
account_find_build_from_iban(session=session)
except Exception as e:
print(f"Error occured on find build : {e}")
session.rollback()
try:
account_records_find_decision_book(session=session)
except Exception as e:
print(f"Error occured on find decision book : {e}")
session.rollback()
session.close()
session_factory.remove()
print("Build Extractor Service is finished...")

View File

@ -0,0 +1,24 @@
FROM python:3.12-slim
WORKDIR /
ENV PYTHONPATH=/ PYTHONUNBUFFERED=1 PYTHONDONTWRITEBYTECODE=1
RUN apt-get update && apt-get install -y --no-install-recommends gcc && rm -rf /var/lib/apt/lists/* && pip install --no-cache-dir poetry
COPY pyproject.toml ./pyproject.toml
RUN poetry config virtualenvs.create false && poetry install --no-interaction --no-ansi --no-root --only main && pip cache purge && rm -rf ~/.cache/pypoetry
RUN apt-get update && apt-get install -y cron
COPY /ServicesBank/Parser /
COPY /ServicesApi/Schemas /Schemas
COPY /ServicesApi/Controllers /Controllers
RUN touch /var/log/cron.log
RUN chmod +x /entrypoint.sh
RUN chmod +x /run_app.sh
ENTRYPOINT ["/entrypoint.sh"]

View File

@ -0,0 +1,3 @@
# Docs of Build Extractor
Finds build_id, decision_book_id, living_space_id from AccountRecords

View File

@ -0,0 +1,636 @@
import re
import arrow
from json import loads, dumps
from unidecode import unidecode
from difflib import SequenceMatcher
from itertools import permutations
from time import perf_counter
from sqlalchemy import text as sqlalchemy_text
from Controllers.Postgres.engine import get_session_factory
from Schemas.account.account import AccountRecordsPredict, AccountRecords
def clean_text(text):
text = str(text)
text = re.sub(r'\d{8,}', ' ', text)
# text = re.sub(r'\b[A-Za-z0-9]*?[0-9]+[A-Za-z0-9]*?[A-Za-z]+[A-Za-z0-9]*\b|\b[A-Za-z0-9]*?[A-Za-z]+[A-Za-z0-9]*?[0-9]+[A-Za-z0-9]*\b', ' ', text)
text = text.replace("/", " ")
text = text.replace("_", " ")
text_remove_underscore = text.replace("-", " ").replace("+", " ")
text_remove_asterisk = text_remove_underscore.replace("*", " ")
text_remove_comma = text_remove_asterisk.replace(",", " ")
text_remove_dots = text_remove_comma.replace(".", " ")
text_remove_dots = re.sub(r'\s+', ' ', text_remove_dots)
text_remove_dots = text_remove_dots.strip()
return text_remove_dots
def normalize_text(text):
text = text.replace('İ', 'i')
text = text.replace('I', 'ı')
text = text.replace('Ş', 'ş')
text = text.replace('Ğ', 'ğ')
text = text.replace('Ü', 'ü')
text = text.replace('Ö', 'ö')
text = text.replace('Ç', 'ç')
return unidecode(text).lower()
def get_person_initials(person):
parts = [person.get("firstname", ""), person.get("middle_name", ""), person.get("surname", ""), person.get("birthname", "")]
return [unidecode(p.strip())[0].upper() for p in parts if p]
def get_text_initials(matched_text):
return [unidecode(word.strip())[0].upper() for word in matched_text.split() if word.strip()]
def generate_dictonary_of_patterns(people):
"""
completly remove middle_name instead do regex firstName + SomeWord + surname
"""
patterns_dict = {}
for person in people:
person_id = person.get('id')
firstname = person.get('firstname', '').strip() if person.get('firstname') else ""
middle_name = person.get('middle_name', '').strip() if person.get('middle_name') else ""
surname = person.get('surname', '').strip() if person.get('surname') else ""
birthname = person.get('birthname', '').strip() if person.get('birthname') else ""
if not firstname or not surname:
continue
name_parts = {
'firstname': {
'orig': firstname,
'norm': normalize_text(firstname) if firstname else "",
'init': normalize_text(firstname)[0] if firstname else ""
},
'surname': {
'orig': surname,
'norm': normalize_text(surname) if surname else "",
'init': normalize_text(surname)[0] if surname else ""
}
}
if middle_name:
name_parts['middle_name'] = {
'orig': middle_name,
'norm': normalize_text(middle_name) if middle_name else "",
'init': normalize_text(middle_name)[0] if middle_name else ""
}
if birthname and normalize_text(birthname) != normalize_text(surname):
name_parts['birthname'] = {
'orig': birthname,
'norm': normalize_text(birthname),
'init': normalize_text(birthname)[0] if birthname else ""
}
person_patterns = set()
def create_pattern(parts, formats, separators=None):
if separators is None:
separators = [""]
patterns = []
for fmt in formats:
for sep in separators:
pattern_parts = []
for part_type, part_name in fmt:
if part_name in parts and part_type in parts[part_name]:
pattern_parts.append(re.escape(parts[part_name][part_type]))
if pattern_parts:
patterns.append(r"\b" + sep.join(pattern_parts) + r"\b")
return patterns
name_formats = [
[('orig', 'firstname'), ('orig', 'surname')],
[('norm', 'firstname'), ('norm', 'surname')],
[('orig', 'surname'), ('orig', 'firstname')],
[('norm', 'surname'), ('norm', 'firstname')],
]
if 'middle_name' in name_parts:
name_formats = [
[('orig', 'firstname'), ('orig', 'middle_name'), ('orig', 'surname')],
[('norm', 'firstname'), ('norm', 'middle_name'), ('norm', 'surname')],
]
person_patterns.update(create_pattern(name_parts, name_formats, [" ", ""]))
if 'middle_name' in name_parts:
middle_name_formats = [
[('orig', 'firstname'), ('orig', 'middle_name')],
[('norm', 'firstname'), ('norm', 'middle_name')],
[('orig', 'middle_name'), ('orig', 'surname')],
[('norm', 'middle_name'), ('norm', 'surname')],
]
person_patterns.update(create_pattern(name_parts, middle_name_formats, [" ", ""]))
if 'birthname' in name_parts and name_parts['surname']['orig'] != name_parts['birthname']['orig']:
birthname_formats = [
[('orig', 'firstname'), ('orig', 'birthname')],
[('norm', 'firstname'), ('norm', 'birthname')],
[('orig', 'birthname'), ('orig', 'firstname')],
[('norm', 'birthname'), ('norm', 'firstname')],
]
person_patterns.update(create_pattern(name_parts, birthname_formats, [" ", ""]))
initial_formats = [
[('init', 'firstname'), ('init', 'middle_name'), ('init', 'surname')],
[('init', 'firstname'), ('init', 'surname')],
]
person_patterns.update(create_pattern(name_parts, initial_formats, ["", ".", " ", ". "]))
if 'middle_name' in name_parts:
triple_initial_formats = [
[('init', 'firstname'), ('init', 'middle_name'), ('init', 'surname')],
]
person_patterns.update(create_pattern(name_parts, triple_initial_formats, ["", ".", " ", ". "]))
compiled_patterns = [re.compile(pattern, re.IGNORECASE) for pattern in person_patterns]
patterns_dict[person_id] = compiled_patterns
return patterns_dict
def extract_person_name_with_regex(found_dict, process_comment, patterns_dict, people):
cleaned_text = process_comment
all_matches = []
for person_id, patterns in patterns_dict.items():
person = next((p for p in people if p.get('id') == person_id), None)
if not person:
continue
firstname_norm = normalize_text(person.get("firstname", "").strip()) if person.get("firstname") else ""
middle_name_norm = normalize_text(person.get("middle_name", "").strip()) if person.get("middle_name") else ""
surname_norm = normalize_text(person.get("surname", "").strip()) if person.get("surname") else ""
birthname_norm = normalize_text(person.get("birthname", "").strip()) if person.get("birthname") else ""
text_norm = normalize_text(process_comment)
person_matches = []
for pattern in patterns:
for match in pattern.finditer(text_norm):
start, end = match.span()
matched_text = process_comment[start:end]
matched_text_norm = normalize_text(matched_text)
is_valid_match = False
# Strict validation: require both firstname AND surname/birthname
# No single-word matches allowed
if len(matched_text_norm.split()) <= 1:
# Single word matches are not allowed
is_valid_match = False
else:
# For multi-word matches, require firstname AND (surname OR birthname)
has_firstname = firstname_norm and firstname_norm in matched_text_norm
has_surname = surname_norm and surname_norm in matched_text_norm
has_birthname = birthname_norm and birthname_norm in matched_text_norm
# Both firstname and surname/birthname must be present
if (has_firstname and has_surname) or (has_firstname and has_birthname):
is_valid_match = True
if is_valid_match:
person_matches.append({
'matched_text': matched_text,
'start': start,
'end': end
})
if person_matches:
person_matches.sort(key=lambda x: len(x['matched_text']), reverse=True)
non_overlapping_matches = []
for match in person_matches:
overlaps = False
for existing_match in non_overlapping_matches:
if (match['start'] < existing_match['end'] and match['end'] > existing_match['start']):
overlaps = True
break
if not overlaps:
non_overlapping_matches.append(match)
if non_overlapping_matches:
found_dict["name_match"] = person
all_matches.extend([(match, person) for match in non_overlapping_matches])
if all_matches:
all_matches.sort(key=lambda x: x[0]['start'], reverse=True)
for match, person in all_matches:
matched_text = match['matched_text']
matched_words = matched_text.split()
for word in matched_words:
word_norm = normalize_text(word).strip()
if not word_norm:
continue
text_norm = normalize_text(cleaned_text)
for word_match in re.finditer(rf'\b{re.escape(word_norm)}\b', text_norm, re.IGNORECASE):
start, end = word_match.span()
cleaned_text = cleaned_text[:start] + ' ' * (end - start) + cleaned_text[end:]
cleaned_text = re.sub(r'\s+', ' ', cleaned_text).strip()
return found_dict, cleaned_text
def extract_build_parts_info(found_dict, process_comment):
"""
Regex of parts such as :
2 nolu daire
9 NUMARALI DAI
daire 3
3 nolu dairenin
11nolu daire
Daire No 12
2NOLU DAIRE
12 No lu daire
D:10
NO:11
NO :3
"""
# Initialize apartment number variable
apartment_number = None
cleaned_text = process_comment
def clean_text_apartment_number(text, match):
clean_text = text.replace(match.group(0), '').strip()
clean_text = re.sub(r'\s+', ' ', clean_text).strip()
return clean_text
# Pattern 1: X nolu daire (with space)
pattern1 = re.compile(r'(\d+)\s*nolu\s*daire', re.IGNORECASE)
match = pattern1.search(cleaned_text)
if match:
apartment_number = match.group(1)
found_dict['apartment_number'] = apartment_number
return found_dict, clean_text_apartment_number(cleaned_text, match)
# Pattern 4: X nolu dairenin
pattern4 = re.compile(r'(\d+)\s*nolu\s*daire\w*', re.IGNORECASE)
match = pattern4.search(cleaned_text)
if match:
apartment_number = match.group(1)
found_dict['apartment_number'] = apartment_number
return found_dict, clean_text_apartment_number(cleaned_text, match)
# Pattern 5: XNolu daire (without space)
pattern5 = re.compile(r'(\d+)nolu\s*daire', re.IGNORECASE)
match = pattern5.search(cleaned_text)
if match:
apartment_number = match.group(1)
found_dict['apartment_number'] = apartment_number
return found_dict, clean_text_apartment_number(cleaned_text, match)
# Pattern 7: XNOLU DAIRE (all caps, no space)
pattern7 = re.compile(r'(\d+)nolu\s*daire', re.IGNORECASE)
match = pattern7.search(cleaned_text)
if match:
apartment_number = match.group(1)
found_dict['apartment_number'] = apartment_number
return found_dict, clean_text_apartment_number(cleaned_text, match)
# Pattern 8: X No lu daire
pattern8 = re.compile(r'(\d+)\s*no\s*lu\s*daire', re.IGNORECASE)
match = pattern8.search(cleaned_text)
if match:
apartment_number = match.group(1)
found_dict['apartment_number'] = apartment_number
return found_dict, clean_text_apartment_number(cleaned_text, match)
# Pattern 6: Daire No X
pattern6 = re.compile(r'daire\s*no\s*(\d+)', re.IGNORECASE)
match = pattern6.search(cleaned_text)
if match:
apartment_number = match.group(1)
found_dict['apartment_number'] = apartment_number
return found_dict, clean_text_apartment_number(cleaned_text, match)
# Pattern 2: X NUMARALI DAI
pattern2 = re.compile(r'(\d+)\s*numarali\s*dai', re.IGNORECASE)
match = pattern2.search(cleaned_text)
if match:
apartment_number = match.group(1)
found_dict['apartment_number'] = apartment_number
return found_dict, clean_text_apartment_number(cleaned_text, match)
# Pattern 3: daire X
pattern3 = re.compile(r'daire\s*(\d+)', re.IGNORECASE)
match = pattern3.search(cleaned_text)
if match:
apartment_number = match.group(1)
found_dict['apartment_number'] = apartment_number
return found_dict, clean_text_apartment_number(cleaned_text, match)
# Pattern 9: D:X
pattern9 = re.compile(r'd\s*:\s*(\d+)', re.IGNORECASE)
match = pattern9.search(cleaned_text)
if match:
apartment_number = match.group(1)
found_dict['apartment_number'] = apartment_number
return found_dict, clean_text_apartment_number(cleaned_text, match)
# Pattern 10: NO:X or NO :X
pattern10 = re.compile(r'no\s*:\s*(\d+)', re.IGNORECASE)
match = pattern10.search(cleaned_text)
if match:
apartment_number = match.group(1)
found_dict['apartment_number'] = apartment_number
return found_dict, clean_text_apartment_number(cleaned_text, match)
return found_dict, cleaned_text
def extract_months(found_dict, process_comment):
"""
Extract Turkish month names and abbreviations from the process comment
"""
original_text = process_comment
# Updated dictionary with normalized keys for better matching
month_to_number_dict = {
"ocak": 1, "şubat": 2, "mart": 3, "nisan": 4, "mayıs": 5, "haziran": 6,
"temmuz": 7, "ağustos": 8, "eylül": 9, "ekim": 10, "kasım": 11, "aralık": 12,
# Add normalized versions without Turkish characters
"ocak": 1, "subat": 2, "mart": 3, "nisan": 4, "mayis": 5, "haziran": 6,
"temmuz": 7, "agustos": 8, "eylul": 9, "ekim": 10, "kasim": 11, "aralik": 12
}
def clean_text_month(text, match):
clean_text = text.replace(match.group(0), '').strip()
clean_text = re.sub(r'\s+', ' ', clean_text).strip()
return clean_text
def normalize_turkish(text):
"""Properly normalize Turkish text for case-insensitive comparison"""
text = text.lower()
text = text.replace('', 'i') # Handle dotted i properly
text = text.replace('ı', 'i') # Convert dotless i to regular i for matching
text = unidecode(text) # Remove other diacritics
return text
if 'months' not in found_dict:
found_dict['months'] = []
months_found, working_text = False, original_text
for month in turkish_months:
pattern = re.compile(r'\b' + re.escape(month) + r'\b', re.IGNORECASE)
for match in pattern.finditer(original_text):
matched_text = match.group(0)
normalized_month = normalize_turkish(month)
month_number = None
if month.lower() in month_to_number_dict:
month_number = month_to_number_dict[month.lower()]
elif normalized_month in month_to_number_dict:
month_number = month_to_number_dict[normalized_month]
month_info = {'name': month, 'number': month_number}
found_dict['months'].append(month_info)
months_found = True
working_text = working_text.replace(matched_text, '', 1)
for abbr, full_month in turkish_months_abbr.items():
pattern = re.compile(r'\b' + re.escape(abbr) + r'\b', re.IGNORECASE)
for match in pattern.finditer(working_text):
matched_text = match.group(0)
normalized_month = normalize_turkish(full_month)
month_number = None
if full_month.lower() in month_to_number_dict:
month_number = month_to_number_dict[full_month.lower()]
elif normalized_month in month_to_number_dict:
month_number = month_to_number_dict[normalized_month]
month_info = {'name': full_month, 'number': month_number}
found_dict['months'].append(month_info)
months_found = True
working_text = working_text.replace(matched_text, '', 1)
return found_dict, working_text
def extract_year(found_dict, process_comment):
"""
Extract years from the process comment
"""
original_text = process_comment
if 'years' not in found_dict:
found_dict['years'] = []
working_text = original_text
for year in range(start_year, current_year + 1):
pattern = re.compile(r'\b' + str(year) + r'\b', re.IGNORECASE)
for match in pattern.finditer(original_text):
matched_text = match.group(0)
if str(matched_text).isdigit():
found_dict['years'].append(int(matched_text))
working_text = working_text.replace(matched_text, '', 1)
return found_dict, working_text
def extract_payment_type(found_dict, process_comment):
"""
Extract payment type from the process comment
aidat
AİD
aidatı
TADİLAT
YAKIT
yakıt
yakit
"""
original_text = process_comment
working_text = original_text
if 'payment_types' not in found_dict:
found_dict['payment_types'] = []
payment_keywords = {
'aidat': ['aidat', 'aİd', 'aid', 'aidatı', 'aidati'],
'tadilat': ['tadilat', 'tadİlat', 'tadilatı'],
'yakit': ['yakit', 'yakıt', 'yakıtı', 'yakiti']
}
for payment_type, keywords in payment_keywords.items():
for keyword in keywords:
pattern = re.compile(r'\b' + keyword + r'\b', re.IGNORECASE)
for match in pattern.finditer(original_text):
matched_text = match.group(0)
if payment_type not in found_dict['payment_types']:
found_dict['payment_types'].append(payment_type)
working_text = working_text.replace(matched_text, '', 1)
return found_dict, working_text
def main(session, account_records, people):
list_of_regex_patterns = generate_dictonary_of_patterns(people=people)
dicts_found = dict()
dicts_not_found = dict()
count_extracted = 0
for account_record in account_records:
account_record_id = str(account_record["id"])
found_dict = {}
process_comment_iteration = clean_text(text=account_record["process_comment"])
found_dict, cleaned_process_comment = extract_person_name_with_regex(
found_dict=found_dict, process_comment=process_comment_iteration, patterns_dict=list_of_regex_patterns, people=people
)
found_dict, cleaned_process_comment = extract_build_parts_info(
found_dict=found_dict, process_comment=cleaned_process_comment
)
found_dict, cleaned_process_comment = extract_months(
found_dict=found_dict, process_comment=cleaned_process_comment
)
found_dict, cleaned_process_comment = extract_year(
found_dict=found_dict, process_comment=cleaned_process_comment
)
found_dict, cleaned_process_comment = extract_payment_type(
found_dict=found_dict, process_comment=cleaned_process_comment
)
if found_dict:
dicts_found[str(account_record_id)] = found_dict
else:
dicts_not_found[str(account_record_id)] = account_record_id
for id_, item in dicts_found.items():
AccountRecordsPredict.set_session(session)
AccountRecords.set_session(session)
months_are_valid = bool(item.get("months", []))
years_are_valid = bool(item.get("years", []))
payment_types_are_valid = bool(item.get("payment_types", []))
apartment_number_are_valid = bool(item.get("apartment_number", []))
person_name_are_valid = bool(item.get("name_match", []))
account_record_to_save = AccountRecords.query.filter_by(id=int(id_)).first()
save_dict = dict(
account_records_id=account_record_to_save.id, account_records_uu_id=str(account_record_to_save.uu_id), prediction_model="regex", treshold=1, is_first_prediction=False
)
update_dict = dict(prediction_model="regex", treshold=1, is_first_prediction=False)
if any([months_are_valid, years_are_valid, payment_types_are_valid, apartment_number_are_valid, person_name_are_valid]):
count_extracted += 1
if months_are_valid:
print(f"months: {item['months']}")
data_to_save = dumps({"data": item['months']})
prediction_result = AccountRecordsPredict.query.filter_by(account_records_id=account_record_to_save.id, prediction_field="months", prediction_model="regex").first()
if not prediction_result:
created_account_prediction = AccountRecordsPredict.create(**save_dict, prediction_field="months", prediction_result=data_to_save)
created_account_prediction.save()
else:
prediction_result.update(**update_dict, prediction_result=data_to_save)
prediction_result.save()
if years_are_valid:
print(f"years: {item['years']}")
data_to_save = dumps({"data": item['years']})
prediction_result = AccountRecordsPredict.query.filter_by(account_records_id=account_record_to_save.id, prediction_field="years", prediction_model="regex").first()
if not prediction_result:
created_account_prediction = AccountRecordsPredict.create(**save_dict, prediction_field="years", prediction_result=data_to_save)
created_account_prediction.save()
else:
prediction_result.update(**update_dict, prediction_result=data_to_save)
prediction_result.save()
if payment_types_are_valid:
print(f"payment_types: {item['payment_types']}")
data_to_save = dumps({"data": item['payment_types']})
prediction_result = AccountRecordsPredict.query.filter_by(account_records_id=account_record_to_save.id, prediction_field="payment_types", prediction_model="regex").first()
if not prediction_result:
created_account_prediction = AccountRecordsPredict.create(**save_dict, prediction_field="payment_types", prediction_result=data_to_save)
created_account_prediction.save()
else:
prediction_result.update(**update_dict, prediction_result=data_to_save)
prediction_result.save()
if apartment_number_are_valid:
print(f"apartment_number: {item['apartment_number']}")
prediction_result = AccountRecordsPredict.query.filter_by(account_records_id=account_record_to_save.id, prediction_field="apartment_number", prediction_model="regex").first()
if not prediction_result:
created_account_prediction = AccountRecordsPredict.create(**save_dict, prediction_field="apartment_number", prediction_result=item['apartment_number'])
created_account_prediction.save()
else:
prediction_result.update(**update_dict, prediction_result=item['apartment_number'])
prediction_result.save()
if person_name_are_valid:
print(f"person_name: {item['name_match']}")
data_to_save = dumps({"data": item['name_match']})
prediction_result = AccountRecordsPredict.query.filter_by(account_records_id=account_record_to_save.id, prediction_field="person_name", prediction_model="regex").first()
if not prediction_result:
created_account_prediction = AccountRecordsPredict.create(**save_dict, prediction_field="person_name", prediction_result=data_to_save)
created_account_prediction.save()
else:
prediction_result.update(**update_dict, prediction_result=data_to_save)
prediction_result.save()
print("\n===== SUMMARY =====")
print(f"extracted data total : {count_extracted}")
print(f"not extracted data total : {len(account_records) - count_extracted}")
print(f"Total account records processed : {len(account_records)}")
if __name__ == "__main__":
session_factory = get_session_factory()
session = session_factory()
turkish_months = ["OCAK", "ŞUBAT", "MART", "NİSAN", "MAYIS", "HAZİRAN", "TEMMUZ", "AĞUSTOS", "EYLÜL", "EKİM", "KASIM", "ARALIK"]
turkish_months_abbr = {
"OCA": "OCAK", "SUB": "ŞUBAT", "ŞUB": "ŞUBAT", "MAR": "MART", "NIS": "NİSAN", "MAY": "MAYIS", "HAZ": "HAZİRAN", "HZR": "HAZİRAN",
"TEM": "TEMMUZ", "AGU": "AĞUSTOS", "AGT": "AĞUSTOS", "EYL": "EYLÜL", "EKI": "EKİM", "KAS": "KASIM", "ARA": "ARALIK",
}
start_year = 1950
current_year = arrow.now().year
people_query = sqlalchemy_text("""
SELECT DISTINCT ON (p.id) p.firstname, p.middle_name, p.surname, p.birthname, bl.id
FROM public.people as p
INNER JOIN public.build_living_space as bl ON bl.person_id = p.id
INNER JOIN public.build_parts as bp ON bp.id = bl.build_parts_id
INNER JOIN public.build as b ON b.id = bp.build_id
WHERE b.id = 1
ORDER BY p.id
""")
people_raw = session.execute(people_query).all()
remove_duplicate = list()
clean_people_list = list()
for person in people_raw:
merged_name = f"{person[0]} {person[1]} {person[2]} {person[3]}"
if merged_name not in remove_duplicate:
clean_people_list.append(person)
remove_duplicate.append(merged_name)
people = [{"firstname": p[0], "middle_name": p[1], "surname": p[2], "birthname": p[3], 'id': p[4]} for p in clean_people_list]
query_account_records = sqlalchemy_text("""
SELECT a.id, a.iban, a.bank_date, a.process_comment FROM public.account_records as a where currency_value > 0
""") # and bank_date::date >= '2020-01-01'
account_records = session.execute(query_account_records).all()
account_records = [{"id": ar[0], "iban": ar[1], "bank_date": ar[2], "process_comment": ar[3]} for ar in account_records]
try:
main(session=session, account_records=account_records, people=people)
except Exception as e:
print(f"{e}")
session.close()
session_factory.remove()

View File

@ -0,0 +1,26 @@
FROM python:3.12-slim
WORKDIR /
RUN apt-get update && apt-get install -y --no-install-recommends gcc && rm -rf /var/lib/apt/lists/* && pip install --no-cache-dir poetry
COPY /ServicesBank/RoutineEmail/pyproject.toml ./pyproject.toml
RUN poetry config virtualenvs.create false && poetry install --no-interaction --no-ansi --no-root --only main && pip cache purge && rm -rf ~/.cache/pypoetry
RUN apt-get update && apt-get install -y cron
COPY /ServicesBank/RoutineEmail /
RUN chmod +x /run_app.sh
COPY /ServicesBank/RoutineEmail /
COPY /ServicesApi/Controllers /ServicesApi/Controllers
COPY /ServicesBank/Depends/config.py /ServicesBank/Depends/config.py
COPY /ServicesBank/Depends/template_accounts.html /template_accounts.html
ENV PYTHONPATH=/ PYTHONUNBUFFERED=1 PYTHONDONTWRITEBYTECODE=1
RUN touch /var/log/cron.log
RUN chmod +x /entrypoint.sh
ENTRYPOINT ["/entrypoint.sh"]

View File

@ -0,0 +1,69 @@
# Routine Email Service
## Overview
This service sends automated email reports about account records at scheduled times using cron. It retrieves account records from a PostgreSQL database, formats them into an HTML email, and sends them to specified recipients.
## Environment Setup
The service requires the following environment variables:
### Email Configuration
- `EMAIL_HOST`: SMTP server address (e.g., "10.10.2.34")
- `EMAIL_USERNAME`: Email sender address (e.g., "example@domain.com")
- `EMAIL_PASSWORD`: Email password (sensitive)
- `EMAIL_PORT`: SMTP port (e.g., 587)
- `EMAIL_SEND`: Flag to enable/disable email sending (1 = enabled)
### Database Configuration
- `DB_HOST`: PostgreSQL server address (e.g., "10.10.2.14")
- `DB_USER`: Database username (e.g., "postgres")
- `DB_PASSWORD`: Database password (sensitive)
- `DB_PORT`: Database port (e.g., 5432)
- `DB_NAME`: Database name (e.g., "postgres")
## Cron Job Configuration
The service is configured to run daily at 11:00 Istanbul Time (08:00 UTC). This is set up in the entrypoint.sh script.
## Docker Container Setup
### Key Files
1. **Dockerfile**: Defines the container image with Python and cron
2. **entrypoint.sh**: Container entrypoint script that:
- Creates an environment file (/env.sh) with all configuration variables
- Sets up the crontab to run run_app.sh at the scheduled time
- Starts the cron service
- Tails the log file for monitoring
3. **run_app.sh**: Script executed by cron that:
- Sources the environment file to get all configuration
- Exports variables to make them available to the Python script
- Runs the Python application
- Logs environment and execution results
### Environment Variable Handling
Cron jobs run with a minimal environment that doesn't automatically include Docker container environment variables. Our solution:
1. Captures all environment variables from Docker to a file at container startup
2. Has the run_app.sh script source this file before execution
3. Explicitly exports all variables to ensure they're available to the Python script
## Logs
Logs are written to `/var/log/cron.log` and can be viewed with:
```bash
docker exec routine_email_service tail -f /var/log/cron.log
```
## Manual Execution
To run the service manually:
```bash
docker exec routine_email_service /run_app.sh
```
## Docker Compose Configuration
In the docker-compose.yml file, the service needs an explicit entrypoint configuration:
```yaml
entrypoint: ["/entrypoint.sh"]
```
This ensures the entrypoint script runs when the container starts.

View File

@ -0,0 +1,157 @@
import arrow
from typing import List, Any
from Schemas import AccountRecords
from jinja2 import Environment, FileSystemLoader
from Controllers.Email.send_email import EmailSendModel, EmailService
def render_email_template(
headers: List[str], rows: List[List[Any]], balance_error: bool, bank_balance: str
) -> str:
"""
Render the HTML email template with the provided data.
Args:
headers: List of column headers for the table
rows: List of data rows for the table
balance_error: Flag indicating if there's a balance discrepancy
bank_balance: Current bank balance formatted as string
Returns:
Rendered HTML template as string
"""
try:
# Look for template in ServiceDepends directory
env = Environment(loader=FileSystemLoader("/"))
template = env.get_template("template_accounts.html")
# Render template with variables
return template.render(
headers=headers,
rows=rows,
bank_balance=bank_balance,
balance_error=balance_error,
today=str(arrow.now().date()),
)
except Exception as e:
print("Exception render template:", e)
raise
def send_email_to_given_address(send_to: str, html_template: str) -> bool:
"""
Send email with the rendered HTML template to the specified address.
Args:
send_to: Email address of the recipient
html_template: Rendered HTML template content
Returns:
Boolean indicating if the email was sent successfully
"""
today = arrow.now()
subject = f"{str(today.date())} Gunes Apt. Cari Durum Bilgilendirme Raporu"
# Create email parameters using EmailSendModel
email_params = EmailSendModel(
subject=subject,
html=html_template,
receivers=[send_to],
text=f"Gunes Apt. Cari Durum Bilgilendirme Raporu - {today.date()}",
)
try:
# Use the context manager to handle connection errors
with EmailService.new_session() as email_session:
# Send email through the service
return EmailService.send_email(email_session, email_params)
except Exception as e:
print(f"Exception send email: {e}")
return False
def set_account_records_to_send_email() -> bool:
"""
Retrieve account records from the database, format them, and send an email report.
Usage:
from app import set_account_records_to_send_email
Returns:
Boolean indicating if the process completed successfully
"""
# Get database session and retrieve records
with AccountRecords.new_session() as db_session:
account_records_query = AccountRecords.filter_all(db=db_session).query
# Get the 3 most recent records
account_records: List[AccountRecords] = (
account_records_query.order_by(
AccountRecords.bank_date.desc(),
AccountRecords.iban.desc(),
)
.limit(3)
.all()
)
# Check if we have enough records
if len(account_records) < 2:
print(f"Not enough records found: {len(account_records)}")
return False
# Check for balance discrepancy
first_record, second_record = account_records[0], account_records[1]
expected_second_balance = (
first_record.bank_balance - first_record.currency_value
)
balance_error = expected_second_balance != second_record.bank_balance
if balance_error:
print(
f"Balance error detected {expected_second_balance} != {second_record.bank_balance}"
)
# Format rows for the email template
list_of_rows = []
for record in account_records:
list_of_rows.append(
[
record.bank_date.strftime("%d/%m/%Y %H:%M"),
record.process_comment,
f"{record.currency_value:,.2f}",
f"{record.bank_balance:,.2f}",
]
)
# Get the most recent bank balance
last_bank_balance = sorted(
account_records, key=lambda x: x.bank_date, reverse=True
)[0].bank_balance
# Define headers for the table
headers = [
"Ulaştığı Tarih",
"Banka Transaksiyonu Ek Bilgi",
"Aktarım Değeri",
"Banka Bakiyesi",
]
# Recipient email address
send_to = "karatay@mehmetkaratay.com.tr"
# Render email template
html_template = render_email_template(
headers=headers,
rows=list_of_rows,
balance_error=balance_error,
bank_balance=f"{last_bank_balance:,.2f}",
)
# Send the email
return send_email_to_given_address(send_to=send_to, html_template=html_template)
if __name__ == "__main__":
success = set_account_records_to_send_email()
print("Email sent successfully" if success else "Failed to send email")
exit_code = 0 if success else 1
exit(exit_code)

View File

@ -0,0 +1,29 @@
#!/bin/bash
# Create environment file that will be available to cron jobs
echo "# Environment variables for cron jobs" > /env.sh
echo "EMAIL_HOST=\"$EMAIL_HOST\"" >> /env.sh
echo "EMAIL_USERNAME=\"$EMAIL_USERNAME\"" >> /env.sh
echo "EMAIL_PASSWORD=\"$EMAIL_PASSWORD\"" >> /env.sh
echo "EMAIL_PORT=$EMAIL_PORT" >> /env.sh
echo "EMAIL_SEND=$EMAIL_SEND" >> /env.sh
echo "DB_HOST=\"$DB_HOST\"" >> /env.sh
echo "DB_USER=\"$DB_USER\"" >> /env.sh
echo "DB_PASSWORD=\"$DB_PASSWORD\"" >> /env.sh
echo "DB_PORT=$DB_PORT" >> /env.sh
echo "DB_NAME=\"$DB_NAME\"" >> /env.sh
# Add Python environment variables
echo "PYTHONPATH=/" >> /env.sh
echo "PYTHONUNBUFFERED=1" >> /env.sh
echo "PYTHONDONTWRITEBYTECODE=1" >> /env.sh
# Make the environment file available to cron
echo "0 8 * * * /run_app.sh >> /var/log/cron.log 2>&1" > /tmp/crontab_list
crontab /tmp/crontab_list
# Start cron
cron
# Tail the log file
tail -f /var/log/cron.log

View File

@ -0,0 +1,17 @@
[project]
name = "routineemailservice"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"arrow>=1.3.0",
"redbox>=0.2.1",
"redis>=5.2.1",
"pydantic-settings>=2.8.1",
"sqlalchemy-mixins>=2.0.5",
"fastapi>=0.115.11",
"jinja2>=3.1.6",
"psycopg2-binary>=2.9.10",
"redmail>=0.6.0",
]

View File

@ -0,0 +1,24 @@
#!/bin/bash
# Source the environment file directly
. /env.sh
# Re-export all variables to ensure they're available to the Python script
export EMAIL_HOST
export EMAIL_USERNAME
export EMAIL_PASSWORD
export EMAIL_PORT
export EMAIL_SEND
export DB_HOST
export DB_USER
export DB_PASSWORD
export DB_PORT
export DB_NAME
# Python environment variables
export PYTHONPATH
export PYTHONUNBUFFERED
export PYTHONDONTWRITEBYTECODE
env >> /var/log/cron.log
/usr/local/bin/python /app.py

View File

@ -0,0 +1,161 @@
services:
email_service:
container_name: email_service
build:
context: .
dockerfile: ServicesBank/Email/Dockerfile
networks:
- bank-services-network
environment:
- MAILBOX=bilgilendirme@ileti.isbank.com.tr
- MAIN_MAIL=karatay.berkay@gmail.com
- INFO_MAIL=mehmet.karatay@hotmail.com
- EMAIL_HOST=10.10.2.34
- EMAIL_USERNAME=isbank@mehmetkaratay.com.tr
- EMAIL_PASSWORD=system
- EMAIL_PORT=993
- EMAIL_SEND_PORT=587
- EMAIL_SLEEP=60
- AUTHORIZE_IBAN=4245-0093333
- REDIS_HOST=10.10.2.15
- REDIS_PORT=6379
- REDIS_PASSWORD=your_strong_password_here
restart: unless-stopped
volumes:
- tempory-email-service:/tmp
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
finder_build_extractor:
container_name: finder_build_extractor
env_file:
- api_env.env
build:
context: .
dockerfile: ServicesBank/Finder/BuildExtractor/Dockerfile
networks:
- bank-services-network
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
cpus: 0.25
mem_limit: 512m
finder_payment_service:
container_name: finder_payment_service
env_file:
- api_env.env
build:
context: .
dockerfile: ServicesBank/Finder/Payment/Dockerfile
networks:
- bank-services-network
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
cpus: 0.25
mem_limit: 512m
parser_service:
container_name: parser_service
build:
context: .
dockerfile: ServicesBank/Parser/Dockerfile
networks:
- bank-services-network
env_file:
- api_env.env
restart: unless-stopped
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
# writer_service:
# container_name: writer_service
# build:
# context: .
# dockerfile: ServicesBank/WriterService/Dockerfile
# networks:
# - bank-services-network
# environment:
# - REDIS_HOST=10.10.2.15
# - REDIS_PORT=6379
# - REDIS_PASSWORD=your_strong_password_here
# - DB_HOST=10.10.2.14
# - DB_PORT=5432
# - DB_USER=postgres
# - DB_PASSWORD=password
# - DB_NAME=postgres
# restart: unless-stopped
# logging:
# driver: "json-file"
# options:
# max-size: "10m"
# max-file: "3"
routine_email_service:
container_name: routine_email_service
build:
context: .
dockerfile: ServicesBank/RoutineEmailService/Dockerfile
entrypoint: ["/entrypoint.sh"]
networks:
- bank-services-network
environment:
- EMAIL_HOST=10.10.2.34
- EMAIL_USERNAME=karatay@mehmetkaratay.com.tr
- EMAIL_PASSWORD=system
- EMAIL_PORT=587
- EMAIL_SEND=1
- DB_HOST=10.10.2.14
- DB_PORT=5432
- DB_USER=postgres
- DB_PASSWORD=password
- DB_NAME=postgres
restart: unless-stopped
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
# sender_service:
# container_name: sender_service
# build:
# context: .
# dockerfile: ServicesBank/SenderService/Dockerfile
# networks:
# - bank-services-network
# environment:
# - EMAIL_HOST=10.10.2.34
# - EMAIL_USERNAME=karatay@mehmetkaratay.com.tr
# - EMAIL_PASSWORD=system
# - EMAIL_PORT=587
# - EMAIL_SEND=1
# - DB_HOST=10.10.2.14
# - DB_PORT=5432
# - DB_USER=postgres
# - DB_PASSWORD=password
# - DB_NAME=postgres
# restart: unless-stopped
# logging:
# driver: "json-file"
# options:
# max-size: "10m"
# max-file: "3"
networks:
bank-services-network:
driver: bridge
volumes:
tempory-email-service:

View File

@ -189,76 +189,45 @@ services:
- "8003:8003"
# restart: unless-stopped python3 app_accounts.py
finder_build_from_iban_service:
container_name: finder_build_from_iban_service
env_file:
- api_env.env
build:
context: .
dockerfile: ServicesBank/Finder/BuildFromIban/Dockerfile
networks:
- wag-services
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
# finder_build_living_space_service:
# container_name: finder_build_living_space_service
# env_file:
# - api_env.env
# build:
# context: .
# dockerfile: ServicesBank/Finder/BuildLivingSpace/Dockerfile
# networks:
# - wag-services
# logging:
# driver: "json-file"
# options:
# max-size: "10m"
# max-file: "3"
finder_build_living_space_service:
container_name: finder_build_living_space_service
env_file:
- api_env.env
build:
context: .
dockerfile: ServicesBank/Finder/BuildLivingSpace/Dockerfile
networks:
- wag-services
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
# finder_decision_book_service:
# container_name: finder_decision_book_service
# env_file:
# - api_env.env
# build:
# context: .
# dockerfile: ServicesBank/Finder/DecisionBook/Dockerfile
# networks:
# - wag-services
# logging:
# driver: "json-file"
# options:
# max-size: "10m"
# max-file: "3"
finder_decision_book_service:
container_name: finder_decision_book_service
env_file:
- api_env.env
build:
context: .
dockerfile: ServicesBank/Finder/DecisionBook/Dockerfile
networks:
- wag-services
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
# zemberek-api:
# build:
# context: .
# dockerfile: ServicesBank/Zemberek/Dockerfile
# container_name: zemberek-api
# ports:
# - "8111:8111"
# restart: unless-stopped
zemberek-api:
build:
context: .
dockerfile: ServicesBank/Zemberek/Dockerfile
container_name: zemberek-api
ports:
- "8111:8111"
restart: unless-stopped
finder_payment_service:
container_name: finder_payment_service
env_file:
- api_env.env
build:
context: .
dockerfile: ServicesBank/Finder/Payment/Dockerfile
networks:
- wag-services
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
# cpus: 0.25
# mem_limit: 512m
# address_service:
# container_name: address_service
@ -354,7 +323,7 @@ services:
context: .
dockerfile: ServicesApi/Builds/Initial/Dockerfile
environment:
- SET_ALEMBIC=0
- SET_ALEMBIC=1
networks:
- wag-services
env_file:

View File

@ -0,0 +1,26 @@
#!/bin/bash
# Source the environment file directly
. /env.sh
# Re-export all variables to ensure they're available to the Python script
export POSTGRES_USER
export POSTGRES_PASSWORD
export POSTGRES_DB
export POSTGRES_HOST
export POSTGRES_PORT
export POSTGRES_ENGINE
export POSTGRES_POOL_PRE_PING
export POSTGRES_POOL_SIZE
export POSTGRES_MAX_OVERFLOW
export POSTGRES_POOL_RECYCLE
export POSTGRES_POOL_TIMEOUT
export POSTGRES_ECHO
# Python environment variables
export PYTHONPATH
export PYTHONUNBUFFERED
export PYTHONDONTWRITEBYTECODE
# env >> /var/log/cron.log
/usr/local/bin/python /runner.py

View File

@ -0,0 +1,93 @@
# Git
.git
.gitignore
.gitattributes
# CI
.codeclimate.yml
.travis.yml
.taskcluster.yml
# Docker
docker-compose.yml
service_app/Dockerfile
.docker
.dockerignore
# Byte-compiled / optimized / DLL files
**/__pycache__/
**/*.py[cod]
# C extensions
*.so
# Distribution / packaging
.Python
service_app/env/
build/
develop-eggs/
dist/
downloads/
eggs/
lib/
lib64/
parts/
sdist/
var/
*.egg-info/
.installed.cfg
*.egg
# 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/
.coverage
.cache
nosetests.xml
coverage.xml
# Translations
*.mo
*.pot
# Django stuff:
*.log
# Sphinx documentation
docs/_build/
# PyBuilder
target/
# Virtual environment
service_app/.env
.venv/
venv/
# PyCharm
.idea
# Python mode for VIM
.ropeproject
**/.ropeproject
# Vim swap files
**/*.swp
# VS Code
.vscode/
test_application/

View File

@ -0,0 +1,30 @@
#!/bin/bash
# Create environment file that will be available to cron jobs
echo "POSTGRES_USER=\"$POSTGRES_USER\"" >> /env.sh
echo "POSTGRES_PASSWORD=\"$POSTGRES_PASSWORD\"" >> /env.sh
echo "POSTGRES_DB=\"$POSTGRES_DB\"" >> /env.sh
echo "POSTGRES_HOST=\"$POSTGRES_HOST\"" >> /env.sh
echo "POSTGRES_PORT=$POSTGRES_PORT" >> /env.sh
echo "POSTGRES_ENGINE=\"$POSTGRES_ENGINE\"" >> /env.sh
echo "POSTGRES_POOL_PRE_PING=\"$POSTGRES_POOL_PRE_PING\"" >> /env.sh
echo "POSTGRES_POOL_SIZE=$POSTGRES_POOL_SIZE" >> /env.sh
echo "POSTGRES_MAX_OVERFLOW=$POSTGRES_MAX_OVERFLOW" >> /env.sh
echo "POSTGRES_POOL_RECYCLE=$POSTGRES_POOL_RECYCLE" >> /env.sh
echo "POSTGRES_POOL_TIMEOUT=$POSTGRES_POOL_TIMEOUT" >> /env.sh
echo "POSTGRES_ECHO=\"$POSTGRES_ECHO\"" >> /env.sh
# Add Python environment variables
echo "PYTHONPATH=/" >> /env.sh
echo "PYTHONUNBUFFERED=1" >> /env.sh
echo "PYTHONDONTWRITEBYTECODE=1" >> /env.sh
# Make the environment file available to cron
echo "*/15 * * * * /run_app.sh >> /var/log/cron.log 2>&1" > /tmp/crontab_list
crontab /tmp/crontab_list
# Start cron
cron
# Tail the log file
tail -f /var/log/cron.log

View File

@ -0,0 +1,93 @@
# Git
.git
.gitignore
.gitattributes
# CI
.codeclimate.yml
.travis.yml
.taskcluster.yml
# Docker
docker-compose.yml
service_app/Dockerfile
.docker
.dockerignore
# Byte-compiled / optimized / DLL files
**/__pycache__/
**/*.py[cod]
# C extensions
*.so
# Distribution / packaging
.Python
service_app/env/
build/
develop-eggs/
dist/
downloads/
eggs/
lib/
lib64/
parts/
sdist/
var/
*.egg-info/
.installed.cfg
*.egg
# 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/
.coverage
.cache
nosetests.xml
coverage.xml
# Translations
*.mo
*.pot
# Django stuff:
*.log
# Sphinx documentation
docs/_build/
# PyBuilder
target/
# Virtual environment
service_app/.env
.venv/
venv/
# PyCharm
.idea
# Python mode for VIM
.ropeproject
**/.ropeproject
# Vim swap files
**/*.swp
# VS Code
.vscode/
test_application/

View File

@ -0,0 +1,30 @@
#!/bin/bash
# Create environment file that will be available to cron jobs
echo "POSTGRES_USER=\"$POSTGRES_USER\"" >> /env.sh
echo "POSTGRES_PASSWORD=\"$POSTGRES_PASSWORD\"" >> /env.sh
echo "POSTGRES_DB=\"$POSTGRES_DB\"" >> /env.sh
echo "POSTGRES_HOST=\"$POSTGRES_HOST\"" >> /env.sh
echo "POSTGRES_PORT=$POSTGRES_PORT" >> /env.sh
echo "POSTGRES_ENGINE=\"$POSTGRES_ENGINE\"" >> /env.sh
echo "POSTGRES_POOL_PRE_PING=\"$POSTGRES_POOL_PRE_PING\"" >> /env.sh
echo "POSTGRES_POOL_SIZE=$POSTGRES_POOL_SIZE" >> /env.sh
echo "POSTGRES_MAX_OVERFLOW=$POSTGRES_MAX_OVERFLOW" >> /env.sh
echo "POSTGRES_POOL_RECYCLE=$POSTGRES_POOL_RECYCLE" >> /env.sh
echo "POSTGRES_POOL_TIMEOUT=$POSTGRES_POOL_TIMEOUT" >> /env.sh
echo "POSTGRES_ECHO=\"$POSTGRES_ECHO\"" >> /env.sh
# Add Python environment variables
echo "PYTHONPATH=/" >> /env.sh
echo "PYTHONUNBUFFERED=1" >> /env.sh
echo "PYTHONDONTWRITEBYTECODE=1" >> /env.sh
# Make the environment file available to cron
echo "*/15 * * * * /run_app.sh >> /var/log/cron.log 2>&1" > /tmp/crontab_list
crontab /tmp/crontab_list
# Start cron
cron
# Tail the log file
tail -f /var/log/cron.log

View File

@ -0,0 +1,26 @@
#!/bin/bash
# Source the environment file directly
. /env.sh
# Re-export all variables to ensure they're available to the Python script
export POSTGRES_USER
export POSTGRES_PASSWORD
export POSTGRES_DB
export POSTGRES_HOST
export POSTGRES_PORT
export POSTGRES_ENGINE
export POSTGRES_POOL_PRE_PING
export POSTGRES_POOL_SIZE
export POSTGRES_MAX_OVERFLOW
export POSTGRES_POOL_RECYCLE
export POSTGRES_POOL_TIMEOUT
export POSTGRES_ECHO
# Python environment variables
export PYTHONPATH
export PYTHONUNBUFFERED
export PYTHONDONTWRITEBYTECODE
# env >> /var/log/cron.log
/usr/local/bin/python /runner.py