push
This commit is contained in:
0
app/models/__init__.py
Normal file
0
app/models/__init__.py
Normal file
10
app/models/audit_log.py
Normal file
10
app/models/audit_log.py
Normal file
@@ -0,0 +1,10 @@
|
||||
from app.extensions import db
|
||||
from app.models.base import TimestampMixin
|
||||
class AuditLog(TimestampMixin, db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
user_id = db.Column(db.Integer, db.ForeignKey('user.id'))
|
||||
action = db.Column(db.String(64), nullable=False)
|
||||
target_type = db.Column(db.String(64), nullable=False)
|
||||
target_id = db.Column(db.Integer)
|
||||
remote_addr = db.Column(db.String(64))
|
||||
details = db.Column(db.Text)
|
||||
6
app/models/base.py
Normal file
6
app/models/base.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from datetime import datetime
|
||||
from app.extensions import db
|
||||
|
||||
class TimestampMixin:
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
|
||||
50
app/models/catalog.py
Normal file
50
app/models/catalog.py
Normal file
@@ -0,0 +1,50 @@
|
||||
from app.extensions import db
|
||||
from app.models.base import TimestampMixin
|
||||
|
||||
|
||||
class Customer(TimestampMixin, db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
company_id = db.Column(db.Integer, db.ForeignKey('company.id'), nullable=False, index=True)
|
||||
name = db.Column(db.String(255), nullable=False)
|
||||
tax_id = db.Column(db.String(32), index=True, default='')
|
||||
email = db.Column(db.String(255), default='')
|
||||
address = db.Column(db.String(255), default='')
|
||||
regon = db.Column(db.String(32), default='')
|
||||
is_active = db.Column(db.Boolean, default=True, nullable=False)
|
||||
|
||||
company = db.relationship('Company', backref=db.backref('customers', lazy='dynamic', cascade='all, delete-orphan'))
|
||||
|
||||
__table_args__ = (db.UniqueConstraint('company_id', 'name', name='uq_customer_company_name'),)
|
||||
|
||||
|
||||
class Product(TimestampMixin, db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
company_id = db.Column(db.Integer, db.ForeignKey('company.id'), nullable=False, index=True)
|
||||
name = db.Column(db.String(255), nullable=False)
|
||||
sku = db.Column(db.String(64), default='')
|
||||
unit = db.Column(db.String(16), default='szt.')
|
||||
net_price = db.Column(db.Numeric(12, 2), nullable=False, default=0)
|
||||
vat_rate = db.Column(db.Numeric(5, 2), nullable=False, default=23)
|
||||
split_payment_default = db.Column(db.Boolean, default=False, nullable=False)
|
||||
is_active = db.Column(db.Boolean, default=True, nullable=False)
|
||||
|
||||
company = db.relationship('Company', backref=db.backref('products', lazy='dynamic', cascade='all, delete-orphan'))
|
||||
|
||||
__table_args__ = (db.UniqueConstraint('company_id', 'name', name='uq_product_company_name'),)
|
||||
|
||||
|
||||
class InvoiceLine(TimestampMixin, db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
invoice_id = db.Column(db.Integer, db.ForeignKey('invoice.id'), nullable=False, index=True)
|
||||
product_id = db.Column(db.Integer, db.ForeignKey('product.id'), index=True)
|
||||
description = db.Column(db.String(255), nullable=False)
|
||||
quantity = db.Column(db.Numeric(12, 2), nullable=False, default=1)
|
||||
unit = db.Column(db.String(16), default='szt.')
|
||||
unit_net = db.Column(db.Numeric(12, 2), nullable=False, default=0)
|
||||
vat_rate = db.Column(db.Numeric(5, 2), nullable=False, default=23)
|
||||
net_amount = db.Column(db.Numeric(12, 2), nullable=False, default=0)
|
||||
vat_amount = db.Column(db.Numeric(12, 2), nullable=False, default=0)
|
||||
gross_amount = db.Column(db.Numeric(12, 2), nullable=False, default=0)
|
||||
|
||||
invoice = db.relationship('Invoice', backref=db.backref('lines', lazy='dynamic', cascade='all, delete-orphan'))
|
||||
product = db.relationship('Product')
|
||||
29
app/models/company.py
Normal file
29
app/models/company.py
Normal file
@@ -0,0 +1,29 @@
|
||||
from app.extensions import db
|
||||
from app.models.base import TimestampMixin
|
||||
|
||||
|
||||
class Company(TimestampMixin, db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
name = db.Column(db.String(255), nullable=False, unique=True)
|
||||
tax_id = db.Column(db.String(32), index=True)
|
||||
regon = db.Column(db.String(32), index=True, default='')
|
||||
address = db.Column(db.String(255), default='')
|
||||
bank_account = db.Column(db.String(64), default='')
|
||||
is_active = db.Column(db.Boolean, default=True, nullable=False)
|
||||
sync_enabled = db.Column(db.Boolean, default=False, nullable=False)
|
||||
sync_interval_minutes = db.Column(db.Integer, default=60, nullable=False)
|
||||
notification_enabled = db.Column(db.Boolean, default=True, nullable=False)
|
||||
note = db.Column(db.Text)
|
||||
|
||||
|
||||
class UserCompanyAccess(TimestampMixin, db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False, index=True)
|
||||
company_id = db.Column(db.Integer, db.ForeignKey('company.id'), nullable=False, index=True)
|
||||
access_level = db.Column(db.String(20), default='full', nullable=False)
|
||||
|
||||
user = db.relationship('User', back_populates='company_access')
|
||||
company = db.relationship('Company', back_populates='user_access')
|
||||
|
||||
|
||||
Company.user_access = db.relationship('UserCompanyAccess', back_populates='company', cascade='all, delete-orphan')
|
||||
185
app/models/invoice.py
Normal file
185
app/models/invoice.py
Normal file
@@ -0,0 +1,185 @@
|
||||
import enum
|
||||
|
||||
from app.extensions import db
|
||||
from app.models.base import TimestampMixin
|
||||
|
||||
|
||||
invoice_tags = db.Table(
|
||||
'invoice_tags',
|
||||
db.Column('invoice_id', db.Integer, db.ForeignKey('invoice.id'), primary_key=True),
|
||||
db.Column('tag_id', db.Integer, db.ForeignKey('tag.id'), primary_key=True),
|
||||
)
|
||||
|
||||
|
||||
class InvoiceType(enum.Enum):
|
||||
PURCHASE = 'purchase'
|
||||
SALE = 'sale'
|
||||
CORRECTION = 'correction'
|
||||
|
||||
|
||||
class InvoiceStatus(enum.Enum):
|
||||
NEW = 'new'
|
||||
READ = 'read'
|
||||
ACCOUNTED = 'accounted'
|
||||
SENT = 'sent'
|
||||
ARCHIVED = 'archived'
|
||||
NEEDS_ATTENTION = 'needs_attention'
|
||||
ERROR = 'error'
|
||||
|
||||
|
||||
|
||||
INVOICE_TYPE_LABELS = {
|
||||
InvoiceType.PURCHASE: 'Zakupowa',
|
||||
InvoiceType.SALE: 'Sprzedażowa',
|
||||
InvoiceType.CORRECTION: 'Korekta',
|
||||
}
|
||||
|
||||
INVOICE_STATUS_LABELS = {
|
||||
InvoiceStatus.NEW: 'Nowa',
|
||||
InvoiceStatus.READ: 'Odczytana',
|
||||
InvoiceStatus.ACCOUNTED: 'Zaksięgowana',
|
||||
InvoiceStatus.SENT: 'Wysłana',
|
||||
InvoiceStatus.ARCHIVED: 'Archiwalna',
|
||||
InvoiceStatus.NEEDS_ATTENTION: 'Do księgowania',
|
||||
InvoiceStatus.ERROR: 'Błąd',
|
||||
}
|
||||
|
||||
ISSUED_STATUS_LABELS = {
|
||||
'draft': 'Robocza',
|
||||
'pending': 'Oczekuje wysyłki',
|
||||
'issued': 'Wysłana do KSeF',
|
||||
'received': 'Odebrana',
|
||||
'read': 'Odczytana',
|
||||
'accounted': 'Zaksięgowana',
|
||||
'queued': 'W kolejce',
|
||||
'error': 'Błąd',
|
||||
'sent': 'Wysłana',
|
||||
'needs_attention': 'Do księgowania',
|
||||
'issued_mock': 'Wysłana testowo',
|
||||
}
|
||||
|
||||
|
||||
class Invoice(TimestampMixin, db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
company_id = db.Column(db.Integer, db.ForeignKey('company.id'), index=True)
|
||||
ksef_number = db.Column(db.String(128), nullable=False, index=True)
|
||||
invoice_number = db.Column(db.String(128), nullable=False, index=True)
|
||||
|
||||
contractor_name = db.Column(db.String(255), nullable=False, index=True)
|
||||
contractor_nip = db.Column(db.String(32), index=True)
|
||||
contractor_regon = db.Column(db.String(32), index=True)
|
||||
contractor_address = db.Column(db.String(512))
|
||||
|
||||
issue_date = db.Column(db.Date, nullable=False, index=True)
|
||||
received_date = db.Column(db.Date, index=True)
|
||||
fetched_at = db.Column(db.DateTime, index=True)
|
||||
|
||||
net_amount = db.Column(db.Numeric(12, 2), nullable=False, default=0)
|
||||
vat_amount = db.Column(db.Numeric(12, 2), nullable=False, default=0)
|
||||
gross_amount = db.Column(db.Numeric(12, 2), nullable=False, default=0)
|
||||
split_payment = db.Column(db.Boolean, default=False, nullable=False)
|
||||
currency = db.Column(db.String(8), default='PLN')
|
||||
seller_bank_account = db.Column(db.String(64), default='')
|
||||
|
||||
invoice_type = db.Column(db.Enum(InvoiceType), nullable=False, default=InvoiceType.PURCHASE)
|
||||
status = db.Column(db.Enum(InvoiceStatus), nullable=False, default=InvoiceStatus.NEW)
|
||||
|
||||
xml_path = db.Column(db.String(512))
|
||||
pdf_path = db.Column(db.String(512))
|
||||
html_preview = db.Column(db.Text)
|
||||
internal_note = db.Column(db.Text)
|
||||
source_hash = db.Column(db.String(128))
|
||||
read_at = db.Column(db.DateTime)
|
||||
last_synced_at = db.Column(db.DateTime)
|
||||
|
||||
external_metadata = db.Column(db.JSON, default=dict)
|
||||
|
||||
is_unread = db.Column(db.Boolean, default=True, nullable=False)
|
||||
pinned = db.Column(db.Boolean, default=False, nullable=False)
|
||||
queue_accounting = db.Column(db.Boolean, default=False, nullable=False)
|
||||
source = db.Column(db.String(32), default='ksef', nullable=False)
|
||||
|
||||
customer_id = db.Column(db.Integer, db.ForeignKey('customer.id'), index=True)
|
||||
|
||||
issued_to_ksef_at = db.Column(db.DateTime)
|
||||
issued_status = db.Column(db.String(32), default='received', nullable=False)
|
||||
|
||||
tags = db.relationship(
|
||||
'Tag',
|
||||
secondary=invoice_tags,
|
||||
lazy='joined',
|
||||
backref=db.backref('invoices', lazy='dynamic'),
|
||||
)
|
||||
sync_events = db.relationship(
|
||||
'SyncEvent',
|
||||
backref='invoice',
|
||||
lazy='dynamic',
|
||||
cascade='all, delete-orphan',
|
||||
)
|
||||
mail_deliveries = db.relationship(
|
||||
'MailDelivery',
|
||||
backref='invoice',
|
||||
lazy='dynamic',
|
||||
cascade='all, delete-orphan',
|
||||
)
|
||||
notifications = db.relationship(
|
||||
'NotificationLog',
|
||||
backref='invoice',
|
||||
lazy='dynamic',
|
||||
cascade='all, delete-orphan',
|
||||
)
|
||||
company = db.relationship('Company', backref=db.backref('invoices', lazy='dynamic'))
|
||||
customer = db.relationship('Customer', backref=db.backref('invoices', lazy='dynamic'))
|
||||
|
||||
__table_args__ = (
|
||||
db.UniqueConstraint('company_id', 'ksef_number', name='uq_invoice_company_ksef'),
|
||||
)
|
||||
|
||||
@property
|
||||
def month_key(self):
|
||||
return f'{self.issue_date.year}-{self.issue_date.month:02d}'
|
||||
|
||||
@property
|
||||
def invoice_type_label(self):
|
||||
return INVOICE_TYPE_LABELS.get(self.invoice_type, getattr(self.invoice_type, 'value', self.invoice_type))
|
||||
|
||||
@property
|
||||
def status_label(self):
|
||||
return INVOICE_STATUS_LABELS.get(self.status, getattr(self.status, 'value', self.status))
|
||||
|
||||
@property
|
||||
def issued_status_label(self):
|
||||
return ISSUED_STATUS_LABELS.get(self.issued_status, self.issued_status)
|
||||
|
||||
|
||||
class Tag(TimestampMixin, db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
name = db.Column(db.String(64), unique=True, nullable=False)
|
||||
color = db.Column(db.String(32), default='secondary')
|
||||
|
||||
|
||||
class SyncEvent(TimestampMixin, db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
invoice_id = db.Column(db.Integer, db.ForeignKey('invoice.id'), nullable=False)
|
||||
status = db.Column(db.String(32), nullable=False)
|
||||
message = db.Column(db.Text)
|
||||
source = db.Column(db.String(32), default='ksef')
|
||||
|
||||
|
||||
class MailDelivery(TimestampMixin, db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
invoice_id = db.Column(db.Integer, db.ForeignKey('invoice.id'), nullable=False)
|
||||
recipient = db.Column(db.String(255), nullable=False)
|
||||
status = db.Column(db.String(32), default='queued')
|
||||
subject = db.Column(db.String(255))
|
||||
error_message = db.Column(db.Text)
|
||||
sent_at = db.Column(db.DateTime)
|
||||
|
||||
|
||||
class NotificationLog(TimestampMixin, db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
invoice_id = db.Column(db.Integer, db.ForeignKey('invoice.id'))
|
||||
channel = db.Column(db.String(32), nullable=False)
|
||||
status = db.Column(db.String(32), default='queued')
|
||||
message = db.Column(db.Text)
|
||||
sent_at = db.Column(db.DateTime)
|
||||
1
app/models/notification.py
Normal file
1
app/models/notification.py
Normal file
@@ -0,0 +1 @@
|
||||
from app.models.invoice import NotificationLog
|
||||
50
app/models/setting.py
Normal file
50
app/models/setting.py
Normal file
@@ -0,0 +1,50 @@
|
||||
import base64
|
||||
import hashlib
|
||||
from cryptography.fernet import Fernet, InvalidToken
|
||||
from flask import current_app
|
||||
from sqlalchemy.exc import OperationalError, ProgrammingError
|
||||
from app.extensions import db
|
||||
from app.models.base import TimestampMixin
|
||||
|
||||
|
||||
class AppSetting(TimestampMixin, db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
key = db.Column(db.String(128), unique=True, nullable=False, index=True)
|
||||
value = db.Column(db.Text)
|
||||
is_encrypted = db.Column(db.Boolean, default=False, nullable=False)
|
||||
|
||||
@classmethod
|
||||
def _cipher(cls):
|
||||
secret = current_app.config.get('APP_MASTER_KEY', current_app.config.get('SECRET_KEY', 'dev')).encode('utf-8')
|
||||
digest = hashlib.sha256(secret).digest()
|
||||
return Fernet(base64.urlsafe_b64encode(digest))
|
||||
|
||||
@classmethod
|
||||
def get(cls, key, default=None, decrypt=False):
|
||||
try:
|
||||
item = cls.query.filter_by(key=key).first()
|
||||
if not item:
|
||||
return default
|
||||
if decrypt and item.is_encrypted and item.value:
|
||||
try:
|
||||
return cls._cipher().decrypt(item.value.encode('utf-8')).decode('utf-8')
|
||||
except InvalidToken:
|
||||
return default
|
||||
return item.value if item.value is not None else default
|
||||
except (OperationalError, ProgrammingError):
|
||||
return default
|
||||
|
||||
@classmethod
|
||||
def set(cls, key, value, encrypt=False):
|
||||
item = cls.query.filter_by(key=key).first()
|
||||
if not item:
|
||||
item = cls(key=key)
|
||||
db.session.add(item)
|
||||
item.is_encrypted = encrypt
|
||||
if value is None:
|
||||
item.value = None
|
||||
elif encrypt:
|
||||
item.value = cls._cipher().encrypt(str(value).encode('utf-8')).decode('utf-8')
|
||||
else:
|
||||
item.value = str(value)
|
||||
return item
|
||||
18
app/models/sync_log.py
Normal file
18
app/models/sync_log.py
Normal file
@@ -0,0 +1,18 @@
|
||||
from app.extensions import db
|
||||
from app.models.base import TimestampMixin
|
||||
|
||||
|
||||
class SyncLog(TimestampMixin, db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
company_id = db.Column(db.Integer, db.ForeignKey('company.id'), index=True)
|
||||
sync_type = db.Column(db.String(32), default='started')
|
||||
status = db.Column(db.String(32), default='started')
|
||||
started_at = db.Column(db.DateTime, nullable=False)
|
||||
finished_at = db.Column(db.DateTime)
|
||||
processed = db.Column(db.Integer, default=0)
|
||||
total = db.Column(db.Integer, default=0)
|
||||
created = db.Column(db.Integer, default=0)
|
||||
updated = db.Column(db.Integer, default=0)
|
||||
errors = db.Column(db.Integer, default=0)
|
||||
message = db.Column(db.Text)
|
||||
company = db.relationship('Company', backref=db.backref('sync_logs', lazy='dynamic'))
|
||||
40
app/models/user.py
Normal file
40
app/models/user.py
Normal file
@@ -0,0 +1,40 @@
|
||||
from flask_login import UserMixin
|
||||
from werkzeug.security import check_password_hash
|
||||
from app.extensions import db, login_manager
|
||||
from app.models.base import TimestampMixin
|
||||
|
||||
|
||||
class User(UserMixin, TimestampMixin, db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
email = db.Column(db.String(255), unique=True, nullable=False, index=True)
|
||||
name = db.Column(db.String(255), nullable=False)
|
||||
password_hash = db.Column(db.String(255), nullable=False)
|
||||
role = db.Column(db.String(50), default='operator', nullable=False)
|
||||
theme_preference = db.Column(db.String(20), default='light', nullable=False)
|
||||
is_blocked = db.Column(db.Boolean, default=False, nullable=False)
|
||||
force_password_change = db.Column(db.Boolean, default=False, nullable=False)
|
||||
last_login_at = db.Column(db.DateTime)
|
||||
company_access = db.relationship('UserCompanyAccess', back_populates='user', cascade='all, delete-orphan')
|
||||
|
||||
def check_password(self, password):
|
||||
return check_password_hash(self.password_hash, password)
|
||||
|
||||
def companies(self):
|
||||
return [item.company for item in self.company_access if item.company and item.company.is_active]
|
||||
|
||||
def can_access_company(self, company_id):
|
||||
return any(item.company_id == company_id for item in self.company_access)
|
||||
|
||||
def company_access_level(self, company_id):
|
||||
for item in self.company_access:
|
||||
if item.company_id == company_id:
|
||||
return item.access_level
|
||||
return None
|
||||
|
||||
def is_company_readonly(self, company_id):
|
||||
return self.company_access_level(company_id) == 'readonly' or self.role == 'readonly'
|
||||
|
||||
|
||||
@login_manager.user_loader
|
||||
def load_user(user_id):
|
||||
return db.session.get(User, int(user_id))
|
||||
Reference in New Issue
Block a user