init: memleketmeselesi platform — API + migrations
FastAPI + PostgreSQL 16. KYC, issue sistemi, permission/group yönetimi, session yönetimi, API client auth (kışla kapısı), officials/persons CRUD. Migration 0001–0013 dahil.
This commit is contained in:
commit
2498e75594
45 changed files with 3434 additions and 0 deletions
9
.gitignore
vendored
Normal file
9
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
.venv/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
.env
|
||||||
|
*.db
|
||||||
|
*.sqlite
|
||||||
|
/uploads/
|
||||||
|
.DS_Store
|
||||||
65
migrations/0001_locations.sql
Normal file
65
migrations/0001_locations.sql
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
-- Lokasyon hiyerarşisi
|
||||||
|
CREATE TYPE location_type AS ENUM (
|
||||||
|
'ulke', 'bolge', 'il', 'ilce', 'bucak', 'belde', 'koy', 'mahalle', 'diger'
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE locations (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
parent_id BIGINT REFERENCES locations(id) ON DELETE RESTRICT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
slug TEXT NOT NULL UNIQUE,
|
||||||
|
type location_type NOT NULL,
|
||||||
|
latitude NUMERIC(10, 7),
|
||||||
|
longitude NUMERIC(10, 7),
|
||||||
|
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_locations_parent ON locations(parent_id);
|
||||||
|
CREATE INDEX idx_locations_type ON locations(type);
|
||||||
|
|
||||||
|
-- Lokasyon değişiklik geçmişi
|
||||||
|
CREATE TABLE location_history (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
location_id BIGINT NOT NULL REFERENCES locations(id) ON DELETE CASCADE,
|
||||||
|
snapshot JSONB NOT NULL,
|
||||||
|
change_reason TEXT,
|
||||||
|
changed_by BIGINT, -- users tablosu kurulunca FK eklenecek
|
||||||
|
changed_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_location_history_loc ON location_history(location_id);
|
||||||
|
|
||||||
|
-- İdari birim türleri
|
||||||
|
CREATE TABLE administrative_unit_types (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
slug TEXT NOT NULL UNIQUE,
|
||||||
|
description TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
-- İdari birimler
|
||||||
|
CREATE TABLE administrative_units (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
type_id BIGINT NOT NULL REFERENCES administrative_unit_types(id),
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
established_at DATE,
|
||||||
|
abolished_at DATE,
|
||||||
|
is_active BOOLEAN NOT NULL DEFAULT TRUE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_admin_units_type ON administrative_units(type_id);
|
||||||
|
|
||||||
|
-- Lokasyon ↔ idari birim eşlemesi
|
||||||
|
CREATE TABLE location_administrative_units (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
location_id BIGINT NOT NULL REFERENCES locations(id) ON DELETE CASCADE,
|
||||||
|
unit_id BIGINT NOT NULL REFERENCES administrative_units(id) ON DELETE CASCADE,
|
||||||
|
valid_from DATE NOT NULL,
|
||||||
|
valid_until DATE,
|
||||||
|
UNIQUE (location_id, unit_id, valid_from)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_loc_admin_units_loc ON location_administrative_units(location_id);
|
||||||
|
CREATE INDEX idx_loc_admin_units_unit ON location_administrative_units(unit_id);
|
||||||
102
migrations/0002_users_and_permissions.sql
Normal file
102
migrations/0002_users_and_permissions.sql
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
-- KYC durumu
|
||||||
|
CREATE TYPE kyc_status AS ENUM ('none', 'pending', 'verified', 'rejected');
|
||||||
|
|
||||||
|
-- Kullanıcılar
|
||||||
|
CREATE TABLE users (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
email TEXT NOT NULL UNIQUE,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
kyc_status kyc_status NOT NULL DEFAULT 'none',
|
||||||
|
kyc_verified_at TIMESTAMPTZ,
|
||||||
|
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
last_login_at TIMESTAMPTZ
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Kişi profilleri (sahipsiz olabilir)
|
||||||
|
CREATE TABLE persons (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
first_name TEXT NOT NULL,
|
||||||
|
last_name TEXT NOT NULL,
|
||||||
|
birth_year SMALLINT,
|
||||||
|
user_id BIGINT UNIQUE REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_persons_user ON persons(user_id);
|
||||||
|
|
||||||
|
-- Organizasyon türleri
|
||||||
|
CREATE TYPE organization_type AS ENUM (
|
||||||
|
'ngo', 'siyasi_parti', 'medya', 'sirket', 'resmi_kurum', 'diger'
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Organizasyon profilleri
|
||||||
|
CREATE TABLE organizations (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
type organization_type NOT NULL DEFAULT 'diger',
|
||||||
|
parent_id BIGINT REFERENCES organizations(id) ON DELETE RESTRICT,
|
||||||
|
user_id BIGINT UNIQUE REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_organizations_parent ON organizations(parent_id);
|
||||||
|
CREATE INDEX idx_organizations_user ON organizations(user_id);
|
||||||
|
|
||||||
|
-- KYC doğrulama talepleri
|
||||||
|
CREATE TYPE verification_status AS ENUM ('pending', 'approved', 'rejected');
|
||||||
|
|
||||||
|
CREATE TABLE verification_requests (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
id_front_path TEXT NOT NULL,
|
||||||
|
id_back_path TEXT NOT NULL,
|
||||||
|
selfie_path TEXT NOT NULL,
|
||||||
|
status verification_status NOT NULL DEFAULT 'pending',
|
||||||
|
reviewed_by BIGINT REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
review_note TEXT,
|
||||||
|
submitted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
reviewed_at TIMESTAMPTZ
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_verification_user ON verification_requests(user_id);
|
||||||
|
CREATE INDEX idx_verification_status ON verification_requests(status);
|
||||||
|
|
||||||
|
-- İzinler
|
||||||
|
CREATE TABLE permissions (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
key TEXT NOT NULL UNIQUE, -- "issue.create", "moderation.assign" vb.
|
||||||
|
description TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Yetki grupları
|
||||||
|
CREATE TABLE permission_groups (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Grup ↔ izin
|
||||||
|
CREATE TABLE group_permissions (
|
||||||
|
group_id BIGINT NOT NULL REFERENCES permission_groups(id) ON DELETE CASCADE,
|
||||||
|
permission_id BIGINT NOT NULL REFERENCES permissions(id) ON DELETE CASCADE,
|
||||||
|
PRIMARY KEY (group_id, permission_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Kullanıcı ↔ grup
|
||||||
|
CREATE TABLE user_groups (
|
||||||
|
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
group_id BIGINT NOT NULL REFERENCES permission_groups(id) ON DELETE CASCADE,
|
||||||
|
granted_by BIGINT REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
granted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
PRIMARY KEY (user_id, group_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_user_groups_user ON user_groups(user_id);
|
||||||
|
CREATE INDEX idx_user_groups_group ON user_groups(group_id);
|
||||||
|
|
||||||
|
-- location_history.changed_by FK'sini şimdi ekle
|
||||||
|
ALTER TABLE location_history
|
||||||
|
ADD CONSTRAINT fk_location_history_user
|
||||||
|
FOREIGN KEY (changed_by) REFERENCES users(id) ON DELETE SET NULL;
|
||||||
86
migrations/0003_issues.sql
Normal file
86
migrations/0003_issues.sql
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
-- Sorun kategorileri
|
||||||
|
CREATE TABLE issue_categories (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
parent_id BIGINT REFERENCES issue_categories(id) ON DELETE RESTRICT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
slug TEXT NOT NULL UNIQUE,
|
||||||
|
icon TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_issue_categories_parent ON issue_categories(parent_id);
|
||||||
|
|
||||||
|
-- Sorun durumu
|
||||||
|
CREATE TYPE issue_status AS ENUM (
|
||||||
|
'open', 'in_progress', 'resolved', 'rejected', 'duplicate'
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Sorunlar
|
||||||
|
CREATE TABLE issues (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
body TEXT NOT NULL,
|
||||||
|
category_id BIGINT NOT NULL REFERENCES issue_categories(id),
|
||||||
|
location_id BIGINT NOT NULL REFERENCES locations(id),
|
||||||
|
reporter_id BIGINT NOT NULL REFERENCES users(id),
|
||||||
|
status issue_status NOT NULL DEFAULT 'open',
|
||||||
|
resolution_threshold SMALLINT NOT NULL DEFAULT 70, -- % çözüldü oyu eşiği
|
||||||
|
resolved_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_issues_category ON issues(category_id);
|
||||||
|
CREATE INDEX idx_issues_location ON issues(location_id);
|
||||||
|
CREATE INDEX idx_issues_reporter ON issues(reporter_id);
|
||||||
|
CREATE INDEX idx_issues_status ON issues(status);
|
||||||
|
|
||||||
|
-- Sorun ↔ yetkili eşlemesi
|
||||||
|
CREATE TYPE assignment_source AS ENUM ('system', 'moderator', 'official_claim', 'ai');
|
||||||
|
|
||||||
|
CREATE TABLE issue_officials (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
issue_id BIGINT NOT NULL REFERENCES issues(id) ON DELETE CASCADE,
|
||||||
|
person_id BIGINT REFERENCES persons(id) ON DELETE CASCADE,
|
||||||
|
org_id BIGINT REFERENCES organizations(id) ON DELETE CASCADE,
|
||||||
|
unit_id BIGINT REFERENCES administrative_units(id) ON DELETE CASCADE,
|
||||||
|
assigned_by assignment_source NOT NULL DEFAULT 'system',
|
||||||
|
confidence SMALLINT CHECK (confidence BETWEEN 0 AND 100),
|
||||||
|
assigned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
CONSTRAINT at_least_one_target CHECK (
|
||||||
|
person_id IS NOT NULL OR org_id IS NOT NULL OR unit_id IS NOT NULL
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_issue_officials_issue ON issue_officials(issue_id);
|
||||||
|
CREATE INDEX idx_issue_officials_person ON issue_officials(person_id);
|
||||||
|
CREATE INDEX idx_issue_officials_unit ON issue_officials(unit_id);
|
||||||
|
|
||||||
|
-- Oylar
|
||||||
|
CREATE TYPE vote_value AS ENUM ('resolved', 'ongoing');
|
||||||
|
|
||||||
|
CREATE TABLE issue_votes (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
issue_id BIGINT NOT NULL REFERENCES issues(id) ON DELETE CASCADE,
|
||||||
|
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
vote vote_value NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
UNIQUE (issue_id, user_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_issue_votes_issue ON issue_votes(issue_id);
|
||||||
|
CREATE INDEX idx_issue_votes_user ON issue_votes(user_id);
|
||||||
|
|
||||||
|
-- Yorumlar
|
||||||
|
CREATE TABLE issue_comments (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
issue_id BIGINT NOT NULL REFERENCES issues(id) ON DELETE CASCADE,
|
||||||
|
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
parent_id BIGINT REFERENCES issue_comments(id) ON DELETE CASCADE,
|
||||||
|
body TEXT NOT NULL,
|
||||||
|
is_deleted BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_issue_comments_issue ON issue_comments(issue_id);
|
||||||
|
CREATE INDEX idx_issue_comments_parent ON issue_comments(parent_id);
|
||||||
137
migrations/0004_officials_and_reports.sql
Normal file
137
migrations/0004_officials_and_reports.sql
Normal file
|
|
@ -0,0 +1,137 @@
|
||||||
|
-- Seçimler
|
||||||
|
CREATE TYPE election_type AS ENUM (
|
||||||
|
'genel', 'yerel', 'cumhurbaskanligi', 'referandum'
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE elections (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
held_at DATE NOT NULL,
|
||||||
|
type election_type NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Görevdeki yetkililer
|
||||||
|
CREATE TABLE officials (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
person_id BIGINT NOT NULL REFERENCES persons(id) ON DELETE RESTRICT,
|
||||||
|
unit_id BIGINT NOT NULL REFERENCES administrative_units(id),
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
started_at DATE NOT NULL,
|
||||||
|
ended_at DATE,
|
||||||
|
election_id BIGINT REFERENCES elections(id) ON DELETE SET NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_officials_person ON officials(person_id);
|
||||||
|
CREATE INDEX idx_officials_unit ON officials(unit_id);
|
||||||
|
|
||||||
|
-- Yetkili karnesi (periyodik hesaplanan)
|
||||||
|
CREATE TABLE official_stats (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
official_id BIGINT NOT NULL REFERENCES officials(id) ON DELETE CASCADE,
|
||||||
|
period_start DATE NOT NULL,
|
||||||
|
period_end DATE,
|
||||||
|
open_at_start INT NOT NULL DEFAULT 0,
|
||||||
|
new_during INT NOT NULL DEFAULT 0,
|
||||||
|
resolved INT NOT NULL DEFAULT 0,
|
||||||
|
rejected INT NOT NULL DEFAULT 0,
|
||||||
|
score NUMERIC(5, 2),
|
||||||
|
computed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
UNIQUE (official_id, period_start)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_official_stats_official ON official_stats(official_id);
|
||||||
|
|
||||||
|
-- Sorumluluk kural tablosu (AI + moderatör için)
|
||||||
|
CREATE TABLE issue_category_unit_rules (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
category_id BIGINT NOT NULL REFERENCES issue_categories(id) ON DELETE CASCADE,
|
||||||
|
unit_type_id BIGINT NOT NULL REFERENCES administrative_unit_types(id) ON DELETE CASCADE,
|
||||||
|
location_type location_type NOT NULL,
|
||||||
|
confidence SMALLINT NOT NULL DEFAULT 80 CHECK (confidence BETWEEN 0 AND 100),
|
||||||
|
legal_basis TEXT,
|
||||||
|
notes TEXT,
|
||||||
|
UNIQUE (category_id, unit_type_id, location_type)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Mevzuat (RAG için)
|
||||||
|
CREATE TABLE legislation (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
number TEXT,
|
||||||
|
published_at DATE,
|
||||||
|
body TEXT NOT NULL,
|
||||||
|
embedding_done BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Rapor türleri
|
||||||
|
CREATE TABLE report_types (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
slug TEXT NOT NULL UNIQUE,
|
||||||
|
description TEXT,
|
||||||
|
is_active BOOLEAN NOT NULL DEFAULT TRUE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Rapor türü ↔ veri kaynağı
|
||||||
|
CREATE TABLE report_data_sources (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL UNIQUE, -- "issue", "vote", "official_stat"
|
||||||
|
unit_price NUMERIC(10, 6) NOT NULL,
|
||||||
|
description TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE report_type_sources (
|
||||||
|
report_type_id BIGINT NOT NULL REFERENCES report_types(id) ON DELETE CASCADE,
|
||||||
|
data_source_id BIGINT NOT NULL REFERENCES report_data_sources(id) ON DELETE CASCADE,
|
||||||
|
is_required BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
PRIMARY KEY (report_type_id, data_source_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Rapor siparişleri
|
||||||
|
CREATE TYPE report_order_status AS ENUM (
|
||||||
|
'draft', 'pending_payment', 'processing', 'ready', 'failed', 'expired'
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE report_orders (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
user_id BIGINT NOT NULL REFERENCES users(id),
|
||||||
|
report_type_id BIGINT NOT NULL REFERENCES report_types(id),
|
||||||
|
parameters JSONB NOT NULL DEFAULT '{}',
|
||||||
|
row_counts JSONB NOT NULL DEFAULT '{}',
|
||||||
|
calculated_price NUMERIC(10, 2) NOT NULL DEFAULT 0,
|
||||||
|
status report_order_status NOT NULL DEFAULT 'draft',
|
||||||
|
paid_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_report_orders_user ON report_orders(user_id);
|
||||||
|
CREATE INDEX idx_report_orders_status ON report_orders(status);
|
||||||
|
|
||||||
|
-- Üretilen rapor dosyaları
|
||||||
|
CREATE TABLE report_outputs (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
order_id BIGINT NOT NULL UNIQUE REFERENCES report_orders(id) ON DELETE CASCADE,
|
||||||
|
file_path TEXT NOT NULL,
|
||||||
|
file_size BIGINT NOT NULL DEFAULT 0,
|
||||||
|
expires_at TIMESTAMPTZ NOT NULL,
|
||||||
|
download_count SMALLINT NOT NULL DEFAULT 0,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Ödemeler
|
||||||
|
CREATE TYPE payment_status AS ENUM ('pending', 'success', 'failed', 'refunded');
|
||||||
|
|
||||||
|
CREATE TABLE payments (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
order_id BIGINT NOT NULL REFERENCES report_orders(id),
|
||||||
|
user_id BIGINT NOT NULL REFERENCES users(id),
|
||||||
|
amount NUMERIC(10, 2) NOT NULL,
|
||||||
|
provider TEXT NOT NULL,
|
||||||
|
provider_ref TEXT,
|
||||||
|
status payment_status NOT NULL DEFAULT 'pending',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_payments_order ON payments(order_id);
|
||||||
|
CREATE INDEX idx_payments_user ON payments(user_id);
|
||||||
30
migrations/0005_location_changes.sql
Normal file
30
migrations/0005_location_changes.sql
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
-- Lokasyon ayrılma kayıtları
|
||||||
|
-- Bir lokasyonun hangi kaynaktan ayrıldığını tutar.
|
||||||
|
-- Tek kaynaktan ayrılma (Osmaniye←Adana) veya
|
||||||
|
-- çok kaynaktan birleşme (Elbistan ili←KMaraş+Malatya+Kayseri) için çoklu satır.
|
||||||
|
CREATE TABLE location_splits (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
location_id BIGINT NOT NULL REFERENCES locations(id) ON DELETE RESTRICT,
|
||||||
|
source_id BIGINT NOT NULL REFERENCES locations(id) ON DELETE RESTRICT,
|
||||||
|
effective_at DATE NOT NULL,
|
||||||
|
notes TEXT,
|
||||||
|
UNIQUE (location_id, source_id, effective_at)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_location_splits_location ON location_splits(location_id);
|
||||||
|
CREATE INDEX idx_location_splits_source ON location_splits(source_id);
|
||||||
|
|
||||||
|
-- Lokasyon birleşme kayıtları
|
||||||
|
-- Bir lokasyonun hangi hedefe birleştirildiğini tutar.
|
||||||
|
-- Beyoğlu+Şekeroba→Yeniİlçe gibi çok kaynak tek hedef için çoklu satır.
|
||||||
|
CREATE TABLE location_merges (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
source_id BIGINT NOT NULL REFERENCES locations(id) ON DELETE RESTRICT,
|
||||||
|
target_id BIGINT NOT NULL REFERENCES locations(id) ON DELETE RESTRICT,
|
||||||
|
effective_at DATE NOT NULL,
|
||||||
|
notes TEXT,
|
||||||
|
UNIQUE (source_id, target_id, effective_at)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_location_merges_source ON location_merges(source_id);
|
||||||
|
CREATE INDEX idx_location_merges_target ON location_merges(target_id);
|
||||||
38
migrations/0006_auth_tokens.sql
Normal file
38
migrations/0006_auth_tokens.sql
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
CREATE TABLE user_devices (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
device_name TEXT, -- "iPhone 15", "Chrome/Windows" vb.
|
||||||
|
user_agent TEXT,
|
||||||
|
last_ip INET,
|
||||||
|
last_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_user_devices_user ON user_devices(user_id);
|
||||||
|
|
||||||
|
CREATE TABLE access_tokens (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
device_id BIGINT REFERENCES user_devices(id) ON DELETE CASCADE,
|
||||||
|
token_hash TEXT NOT NULL UNIQUE, -- SHA-256(token), düz token istemcide
|
||||||
|
expires_at TIMESTAMPTZ NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_access_tokens_hash ON access_tokens(token_hash);
|
||||||
|
CREATE INDEX idx_access_tokens_user ON access_tokens(user_id);
|
||||||
|
CREATE INDEX idx_access_tokens_expires ON access_tokens(expires_at);
|
||||||
|
|
||||||
|
CREATE TABLE refresh_tokens (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
device_id BIGINT REFERENCES user_devices(id) ON DELETE CASCADE,
|
||||||
|
token_hash TEXT NOT NULL UNIQUE,
|
||||||
|
used_at TIMESTAMPTZ, -- NULL = henüz kullanılmadı
|
||||||
|
revoked BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
expires_at TIMESTAMPTZ NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_refresh_tokens_hash ON refresh_tokens(token_hash);
|
||||||
|
CREATE INDEX idx_refresh_tokens_user ON refresh_tokens(user_id);
|
||||||
75
migrations/0007_permissions_refactor.sql
Normal file
75
migrations/0007_permissions_refactor.sql
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
-- Eski permissions tablosunu yeniden yapılandır
|
||||||
|
DROP TABLE IF EXISTS group_permissions CASCADE;
|
||||||
|
DROP TABLE IF EXISTS permissions CASCADE;
|
||||||
|
|
||||||
|
CREATE TABLE permissions (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
module TEXT NOT NULL, -- "issue", "kyc", "location", "comment", "*"
|
||||||
|
action TEXT NOT NULL, -- "create", "read", "update", "delete", "approve", "assign", "*"
|
||||||
|
description TEXT,
|
||||||
|
UNIQUE (module, action)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE group_permissions (
|
||||||
|
group_id BIGINT NOT NULL REFERENCES permission_groups(id) ON DELETE CASCADE,
|
||||||
|
permission_id BIGINT NOT NULL REFERENCES permissions(id) ON DELETE CASCADE,
|
||||||
|
PRIMARY KEY (group_id, permission_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- is_superuser flag — bu gruptaki herkes tüm izinlere sahip
|
||||||
|
ALTER TABLE permission_groups ADD COLUMN IF NOT EXISTS is_superuser BOOLEAN NOT NULL DEFAULT FALSE;
|
||||||
|
|
||||||
|
-- Temel izinleri seed et
|
||||||
|
INSERT INTO permissions (module, action, description) VALUES
|
||||||
|
-- wildcard
|
||||||
|
('*', '*', 'Tüm modüllerde tüm eylemler'),
|
||||||
|
-- location
|
||||||
|
('location', 'create', 'Yeni lokasyon ekle'),
|
||||||
|
('location', 'read', 'Lokasyon detayını gör'),
|
||||||
|
('location', 'update', 'Lokasyon bilgisi güncelle'),
|
||||||
|
('location', 'delete', 'Lokasyon sil'),
|
||||||
|
('location', 'retype', 'Lokasyon tipini değiştir'),
|
||||||
|
('location', 'reparent', 'Lokasyon hiyerarşisini değiştir'),
|
||||||
|
('location', 'merge', 'Lokasyonları birleştir'),
|
||||||
|
('location', 'split', 'Lokasyon ayrılma kaydı ekle'),
|
||||||
|
-- admin_unit
|
||||||
|
('admin_unit', 'create', 'Yeni idari birim ekle'),
|
||||||
|
('admin_unit', 'update', 'İdari birim güncelle'),
|
||||||
|
('admin_unit', 'close', 'İdari birimi kapat'),
|
||||||
|
('admin_unit', 'assign', 'Lokasyona idari birim ata'),
|
||||||
|
-- issue
|
||||||
|
('issue', 'create', 'Sorun bildir'),
|
||||||
|
('issue', 'read', 'Sorun detayını gör'),
|
||||||
|
('issue', 'update', 'Sorun güncelle'),
|
||||||
|
('issue', 'delete', 'Sorun sil'),
|
||||||
|
('issue', 'assign', 'Sorumlu ata'),
|
||||||
|
('issue', 'resolve', 'Sorunu çözüldü işaretle'),
|
||||||
|
('issue', 'reject', 'Sorunu reddet'),
|
||||||
|
-- comment
|
||||||
|
('comment', 'create', 'Yorum yap'),
|
||||||
|
('comment', 'delete', 'Yorum sil'),
|
||||||
|
-- kyc
|
||||||
|
('kyc', 'approve', 'KYC başvurusu onayla'),
|
||||||
|
('kyc', 'reject', 'KYC başvurusu reddet'),
|
||||||
|
('kyc', 'view', 'KYC belgelerini gör'),
|
||||||
|
-- user
|
||||||
|
('user', 'read', 'Kullanıcı listesi ve detayı'),
|
||||||
|
('user', 'suspend', 'Kullanıcıyı askıya al'),
|
||||||
|
('user', 'assign_group', 'Kullanıcıya grup ata'),
|
||||||
|
-- profile
|
||||||
|
('profile', 'create', 'Kişi/organizasyon profili oluştur'),
|
||||||
|
('profile', 'update', 'Profil güncelle'),
|
||||||
|
('profile', 'delete', 'Profil sil'),
|
||||||
|
-- report
|
||||||
|
('report', 'create', 'Rapor oluştur'),
|
||||||
|
('report', 'read', 'Rapor görüntüle'),
|
||||||
|
('report', 'manage', 'Rapor tiplerini ve fiyatları yönet'),
|
||||||
|
-- moderation
|
||||||
|
('moderation', 'review', 'İçerik inceleme kuyruğunu gör'),
|
||||||
|
('moderation', 'action', 'Moderasyon aksiyonu uygula')
|
||||||
|
ON CONFLICT (module, action) DO NOTHING;
|
||||||
|
|
||||||
|
-- İlk süper kullanıcı grubu
|
||||||
|
INSERT INTO permission_groups (name, description, is_superuser)
|
||||||
|
VALUES ('Süper Yönetici', 'Tüm izinlere sahip grup', TRUE)
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
24
migrations/0008_permissions_scope.sql
Normal file
24
migrations/0008_permissions_scope.sql
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
-- permissions tablosuna scope kolonu ekle
|
||||||
|
-- scope=FALSE → kullanıcının yalnızca kendi içeriğine uygulanır (örn. kendi yorumunu sil)
|
||||||
|
-- scope=TRUE → admin kapsamı, herhangi bir içeriğe uygulanır
|
||||||
|
|
||||||
|
ALTER TABLE permissions ADD COLUMN IF NOT EXISTS scope BOOLEAN NOT NULL DEFAULT FALSE;
|
||||||
|
|
||||||
|
-- Eski unique constraint'i kaldır, scope dahil yeni constraint ekle
|
||||||
|
ALTER TABLE permissions DROP CONSTRAINT IF EXISTS permissions_module_action_key;
|
||||||
|
ALTER TABLE permissions ADD CONSTRAINT permissions_module_action_scope_key UNIQUE (module, action, scope);
|
||||||
|
|
||||||
|
-- Mevcut izinlere scope=FALSE atandı (DEFAULT ile zaten geldi)
|
||||||
|
-- Admin scope varyantlarını ekle
|
||||||
|
INSERT INTO permissions (module, action, description, scope) VALUES
|
||||||
|
-- comment admin scope
|
||||||
|
('comment', 'delete', 'Herhangi bir yorumu sil', TRUE),
|
||||||
|
-- issue admin scope
|
||||||
|
('issue', 'update', 'Herhangi bir sorunu güncelle', TRUE),
|
||||||
|
('issue', 'delete', 'Herhangi bir sorunu sil', TRUE),
|
||||||
|
-- profile admin scope
|
||||||
|
('profile', 'update', 'Herhangi bir profili güncelle', TRUE),
|
||||||
|
('profile', 'delete', 'Herhangi bir profili sil', TRUE),
|
||||||
|
-- report admin scope
|
||||||
|
('report', 'create', 'Herhangi bir kullanıcı için rapor oluştur', TRUE)
|
||||||
|
ON CONFLICT (module, action, scope) DO NOTHING;
|
||||||
11
migrations/0009_session_cleanup.sql
Normal file
11
migrations/0009_session_cleanup.sql
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
-- Süresi dolmuş token'ları periyodik temizlemek için index (pg_cron veya uygulama seviyesi cleanup)
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_access_tokens_expires ON access_tokens(expires_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_expires ON refresh_tokens(expires_at);
|
||||||
|
|
||||||
|
-- Cleanup fonksiyonu — pg_cron yoksa uygulama startup'ında çağrılır
|
||||||
|
CREATE OR REPLACE FUNCTION cleanup_expired_tokens() RETURNS void AS $$
|
||||||
|
BEGIN
|
||||||
|
DELETE FROM access_tokens WHERE expires_at < NOW() - INTERVAL '1 hour';
|
||||||
|
DELETE FROM refresh_tokens WHERE expires_at < NOW() - INTERVAL '1 day' AND (used_at IS NOT NULL OR revoked = TRUE);
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
18
migrations/0010_kyc_upgrade.sql
Normal file
18
migrations/0010_kyc_upgrade.sql
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
-- TC kimlik no şifreli sakla
|
||||||
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS tc_kimlik_enc BYTEA;
|
||||||
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS tc_kimlik_hash TEXT; -- SHA-256, tekrar kayıt kontrolü
|
||||||
|
|
||||||
|
-- verification_requests: eski path kolonları yerine şifreli blob path'leri + view token
|
||||||
|
ALTER TABLE verification_requests ADD COLUMN IF NOT EXISTS enc_key_id TEXT; -- hangi key versiyonu ile şifrelendi
|
||||||
|
ALTER TABLE verification_requests ADD COLUMN IF NOT EXISTS rejection_count INT NOT NULL DEFAULT 0;
|
||||||
|
|
||||||
|
-- Tek seferlik view token'ları (fotoğraf erişimi için)
|
||||||
|
CREATE TABLE IF NOT EXISTS kyc_view_tokens (
|
||||||
|
token TEXT PRIMARY KEY,
|
||||||
|
request_id BIGINT NOT NULL REFERENCES verification_requests(id) ON DELETE CASCADE,
|
||||||
|
created_by BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
expires_at TIMESTAMPTZ NOT NULL,
|
||||||
|
used_at TIMESTAMPTZ
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_kyc_view_tokens_expires ON kyc_view_tokens(expires_at);
|
||||||
55
migrations/0011_issue_categories_seed.sql
Normal file
55
migrations/0011_issue_categories_seed.sql
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
-- Issue kategorileri seed verisi
|
||||||
|
|
||||||
|
INSERT INTO issue_categories (name, slug, icon) VALUES
|
||||||
|
('Altyapı', 'altyapi', 'wrench'),
|
||||||
|
('Çevre', 'cevre', 'leaf'),
|
||||||
|
('Ulaşım', 'ulasim', 'road'),
|
||||||
|
('Eğitim', 'egitim', 'book'),
|
||||||
|
('Sağlık', 'saglik', 'heart'),
|
||||||
|
('Güvenlik', 'guvenlik', 'shield'),
|
||||||
|
('Sosyal Hizmetler', 'sosyal-hizmetler', 'people'),
|
||||||
|
('Ekonomi', 'ekonomi', 'currency'),
|
||||||
|
('Belediye Hizmetleri', 'belediye-hizmetleri','building'),
|
||||||
|
('Diğer', 'diger', 'dots')
|
||||||
|
ON CONFLICT (slug) DO NOTHING;
|
||||||
|
|
||||||
|
-- Alt kategoriler — Altyapı
|
||||||
|
INSERT INTO issue_categories (parent_id, name, slug, icon)
|
||||||
|
SELECT id, 'Su ve Kanalizasyon', 'altyapi-su', 'droplet' FROM issue_categories WHERE slug = 'altyapi'
|
||||||
|
UNION ALL
|
||||||
|
SELECT id, 'Elektrik', 'altyapi-elektrik', 'bolt' FROM issue_categories WHERE slug = 'altyapi'
|
||||||
|
UNION ALL
|
||||||
|
SELECT id, 'Yol ve Kaldırım', 'altyapi-yol', 'road' FROM issue_categories WHERE slug = 'altyapi'
|
||||||
|
UNION ALL
|
||||||
|
SELECT id, 'İnternet / Fiber', 'altyapi-internet', 'wifi' FROM issue_categories WHERE slug = 'altyapi'
|
||||||
|
ON CONFLICT (slug) DO NOTHING;
|
||||||
|
|
||||||
|
-- Alt kategoriler — Çevre
|
||||||
|
INSERT INTO issue_categories (parent_id, name, slug, icon)
|
||||||
|
SELECT id, 'Çöp ve Atık', 'cevre-cop', 'trash' FROM issue_categories WHERE slug = 'cevre'
|
||||||
|
UNION ALL
|
||||||
|
SELECT id, 'Hava Kirliliği','cevre-hava', 'cloud' FROM issue_categories WHERE slug = 'cevre'
|
||||||
|
UNION ALL
|
||||||
|
SELECT id, 'Su Kirliliği', 'cevre-su', 'water' FROM issue_categories WHERE slug = 'cevre'
|
||||||
|
UNION ALL
|
||||||
|
SELECT id, 'Gürültü', 'cevre-gurultu','volume' FROM issue_categories WHERE slug = 'cevre'
|
||||||
|
ON CONFLICT (slug) DO NOTHING;
|
||||||
|
|
||||||
|
-- Alt kategoriler — Ulaşım
|
||||||
|
INSERT INTO issue_categories (parent_id, name, slug, icon)
|
||||||
|
SELECT id, 'Toplu Taşıma', 'ulasim-toplu-tasima', 'bus' FROM issue_categories WHERE slug = 'ulasim'
|
||||||
|
UNION ALL
|
||||||
|
SELECT id, 'Trafik', 'ulasim-trafik', 'traffic' FROM issue_categories WHERE slug = 'ulasim'
|
||||||
|
UNION ALL
|
||||||
|
SELECT id, 'Park', 'ulasim-park', 'parking' FROM issue_categories WHERE slug = 'ulasim'
|
||||||
|
ON CONFLICT (slug) DO NOTHING;
|
||||||
|
|
||||||
|
-- Oy kullanma izni (0007'de tanımlanmamıştı)
|
||||||
|
INSERT INTO permissions (module, action, scope, description)
|
||||||
|
VALUES ('vote', 'create', FALSE, 'Sorun oylamasına katıl')
|
||||||
|
ON CONFLICT (module, action, scope) DO NOTHING;
|
||||||
|
|
||||||
|
-- issue.status — moderatörlerin durum değiştirmesi için (0007'deki resolve/reject'in yerine ek olarak)
|
||||||
|
INSERT INTO permissions (module, action, scope, description)
|
||||||
|
VALUES ('issue', 'status', TRUE, 'Sorun durumunu değiştir (moderatör)')
|
||||||
|
ON CONFLICT (module, action, scope) DO NOTHING;
|
||||||
15
migrations/0012_issue_media.sql
Normal file
15
migrations/0012_issue_media.sql
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
CREATE TABLE issue_media (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
issue_id BIGINT NOT NULL REFERENCES issues(id) ON DELETE CASCADE,
|
||||||
|
uploader_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
file_path TEXT NOT NULL,
|
||||||
|
mime_type TEXT NOT NULL,
|
||||||
|
file_size BIGINT NOT NULL,
|
||||||
|
uploaded_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_issue_media_issue ON issue_media(issue_id);
|
||||||
|
|
||||||
|
INSERT INTO permissions (module, action, scope, description)
|
||||||
|
VALUES ('issue', 'upload_media', FALSE, 'Soruna medya ekle')
|
||||||
|
ON CONFLICT (module, action, scope) DO NOTHING;
|
||||||
7
migrations/0013_api_clients.sql
Normal file
7
migrations/0013_api_clients.sql
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
CREATE TABLE api_clients (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
secret_hash TEXT NOT NULL,
|
||||||
|
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
0
mm_api/__init__.py
Normal file
0
mm_api/__init__.py
Normal file
15
mm_api/db.py
Normal file
15
mm_api/db.py
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
import os
|
||||||
|
import psycopg_pool
|
||||||
|
from pathlib import Path
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
load_dotenv(Path(__file__).parent.parent / ".env")
|
||||||
|
|
||||||
|
DSN = os.environ["DATABASE_URL"]
|
||||||
|
|
||||||
|
pool = psycopg_pool.AsyncConnectionPool(DSN, min_size=2, max_size=10, open=False)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_conn():
|
||||||
|
async with pool.connection() as conn:
|
||||||
|
yield conn
|
||||||
25
mm_api/dependencies.py
Normal file
25
mm_api/dependencies.py
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
from fastapi import Depends, HTTPException, Security
|
||||||
|
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||||
|
from psycopg import AsyncConnection
|
||||||
|
from mm_api.db import get_conn
|
||||||
|
import mm_api.services.auth as auth_svc
|
||||||
|
|
||||||
|
bearer = HTTPBearer(auto_error=False)
|
||||||
|
|
||||||
|
|
||||||
|
async def current_user(
|
||||||
|
credentials: HTTPAuthorizationCredentials = Security(bearer),
|
||||||
|
conn: AsyncConnection = Depends(get_conn),
|
||||||
|
) -> dict:
|
||||||
|
if not credentials:
|
||||||
|
raise HTTPException(401, "Kimlik doğrulama gerekli")
|
||||||
|
user = await auth_svc.get_current_user(conn, credentials.credentials)
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(401, "Geçersiz veya süresi dolmuş token")
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
async def verified_user(user: dict = Depends(current_user)) -> dict:
|
||||||
|
if user["kyc_status"] != "verified":
|
||||||
|
raise HTTPException(403, "Kimlik doğrulaması gerekli")
|
||||||
|
return user
|
||||||
31
mm_api/main.py
Normal file
31
mm_api/main.py
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from mm_api.db import pool
|
||||||
|
from mm_api.routers import locations, admin_units, auth, permissions, kyc, issues, officials, clients
|
||||||
|
from mm_api.middleware import client_auth_middleware
|
||||||
|
import mm_api.services.auth as auth_svc
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
await pool.open()
|
||||||
|
app.state.pool = pool
|
||||||
|
async with pool.connection() as conn:
|
||||||
|
await auth_svc.cleanup_expired_tokens(conn)
|
||||||
|
yield
|
||||||
|
await pool.close()
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(title="Memleketmeselesi API", lifespan=lifespan)
|
||||||
|
|
||||||
|
app.middleware("http")(client_auth_middleware)
|
||||||
|
|
||||||
|
app.include_router(locations.router)
|
||||||
|
app.include_router(admin_units.router)
|
||||||
|
app.include_router(auth.router)
|
||||||
|
app.include_router(permissions.router)
|
||||||
|
app.include_router(kyc.router)
|
||||||
|
app.include_router(issues.router)
|
||||||
|
app.include_router(officials.router)
|
||||||
|
app.include_router(clients.router)
|
||||||
20
mm_api/middleware.py
Normal file
20
mm_api/middleware.py
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
from fastapi import Request
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
from mm_api.services.client import verify_client
|
||||||
|
|
||||||
|
EXEMPT_PATHS = {"/docs", "/redoc", "/openapi.json"}
|
||||||
|
|
||||||
|
|
||||||
|
async def client_auth_middleware(request: Request, call_next):
|
||||||
|
if request.url.path in EXEMPT_PATHS:
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
|
secret = request.headers.get("X-Api-Key")
|
||||||
|
if not secret:
|
||||||
|
return JSONResponse(status_code=401, content={"detail": "API anahtarı gerekli"})
|
||||||
|
|
||||||
|
async with request.app.state.pool.connection() as conn:
|
||||||
|
if not await verify_client(conn, secret):
|
||||||
|
return JSONResponse(status_code=401, content={"detail": "Geçersiz veya devre dışı API anahtarı"})
|
||||||
|
|
||||||
|
return await call_next(request)
|
||||||
0
mm_api/models/__init__.py
Normal file
0
mm_api/models/__init__.py
Normal file
37
mm_api/models/admin_unit.py
Normal file
37
mm_api/models/admin_unit.py
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import Optional
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
|
||||||
|
class AdminUnitType(BaseModel):
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
slug: str
|
||||||
|
description: Optional[str]
|
||||||
|
|
||||||
|
|
||||||
|
class AdminUnit(BaseModel):
|
||||||
|
id: int
|
||||||
|
type_id: int
|
||||||
|
type_name: str
|
||||||
|
name: str
|
||||||
|
established_at: Optional[date]
|
||||||
|
abolished_at: Optional[date]
|
||||||
|
is_active: bool
|
||||||
|
|
||||||
|
|
||||||
|
class AdminUnitCreate(BaseModel):
|
||||||
|
type_id: int
|
||||||
|
name: str
|
||||||
|
established_at: Optional[date] = None
|
||||||
|
|
||||||
|
|
||||||
|
class AdminUnitAssign(BaseModel):
|
||||||
|
unit_id: int
|
||||||
|
valid_from: date
|
||||||
|
valid_until: Optional[date] = None
|
||||||
|
|
||||||
|
|
||||||
|
class AdminUnitClose(BaseModel):
|
||||||
|
abolished_at: date
|
||||||
|
valid_until: date
|
||||||
23
mm_api/models/auth.py
Normal file
23
mm_api/models/auth.py
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
from pydantic import BaseModel, EmailStr
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
class RegisterRequest(BaseModel):
|
||||||
|
email: EmailStr
|
||||||
|
password: str
|
||||||
|
|
||||||
|
|
||||||
|
class LoginRequest(BaseModel):
|
||||||
|
email: EmailStr
|
||||||
|
password: str
|
||||||
|
device_name: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class TokenResponse(BaseModel):
|
||||||
|
access_token: str
|
||||||
|
refresh_token: str
|
||||||
|
expires_in: int # saniye
|
||||||
|
|
||||||
|
|
||||||
|
class RefreshRequest(BaseModel):
|
||||||
|
refresh_token: str
|
||||||
65
mm_api/models/location.py
Normal file
65
mm_api/models/location.py
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import Optional
|
||||||
|
from datetime import date, datetime
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
|
||||||
|
class LocationType(str, Enum):
|
||||||
|
ulke = "ulke"
|
||||||
|
bolge = "bolge"
|
||||||
|
il = "il"
|
||||||
|
ilce = "ilce"
|
||||||
|
bucak = "bucak"
|
||||||
|
belde = "belde"
|
||||||
|
koy = "koy"
|
||||||
|
mahalle = "mahalle"
|
||||||
|
diger = "diger"
|
||||||
|
|
||||||
|
|
||||||
|
class Location(BaseModel):
|
||||||
|
id: int
|
||||||
|
parent_id: Optional[int]
|
||||||
|
name: str
|
||||||
|
slug: str
|
||||||
|
type: LocationType
|
||||||
|
latitude: Optional[float]
|
||||||
|
longitude: Optional[float]
|
||||||
|
is_active: bool
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class LocationCreate(BaseModel):
|
||||||
|
parent_id: Optional[int] = None
|
||||||
|
name: str
|
||||||
|
slug: str
|
||||||
|
type: LocationType
|
||||||
|
latitude: Optional[float] = None
|
||||||
|
longitude: Optional[float] = None
|
||||||
|
|
||||||
|
|
||||||
|
class LocationRetype(BaseModel):
|
||||||
|
new_type: LocationType
|
||||||
|
change_reason: str
|
||||||
|
changed_by: Optional[int] = None # user_id
|
||||||
|
|
||||||
|
|
||||||
|
class LocationReparent(BaseModel):
|
||||||
|
new_parent_id: int
|
||||||
|
change_reason: str
|
||||||
|
changed_by: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
|
class LocationSplitFrom(BaseModel):
|
||||||
|
source_id: int
|
||||||
|
effective_at: date
|
||||||
|
notes: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class LocationMerge(BaseModel):
|
||||||
|
source_ids: list[int] # birleşen lokasyonlar
|
||||||
|
target_id: int # hedef (varsa) veya yeni oluşturulacak
|
||||||
|
effective_at: date
|
||||||
|
notes: Optional[str] = None
|
||||||
|
change_reason: str
|
||||||
|
changed_by: Optional[int] = None
|
||||||
33
mm_api/models/permission.py
Normal file
33
mm_api/models/permission.py
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import Optional
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
|
class Permission(BaseModel):
|
||||||
|
id: int
|
||||||
|
module: str
|
||||||
|
action: str
|
||||||
|
description: Optional[str]
|
||||||
|
|
||||||
|
|
||||||
|
class PermissionGroup(BaseModel):
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
description: Optional[str]
|
||||||
|
is_superuser: bool
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class PermissionGroupCreate(BaseModel):
|
||||||
|
name: str
|
||||||
|
description: Optional[str] = None
|
||||||
|
is_superuser: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class GroupPermissionAssign(BaseModel):
|
||||||
|
permission_ids: list[int]
|
||||||
|
|
||||||
|
|
||||||
|
class UserGroupAssign(BaseModel):
|
||||||
|
user_id: int
|
||||||
|
group_id: int
|
||||||
0
mm_api/routers/__init__.py
Normal file
0
mm_api/routers/__init__.py
Normal file
68
mm_api/routers/admin_units.py
Normal file
68
mm_api/routers/admin_units.py
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from psycopg import AsyncConnection
|
||||||
|
from mm_api.db import get_conn
|
||||||
|
from mm_api.models.admin_unit import AdminUnitCreate, AdminUnitAssign, AdminUnitClose
|
||||||
|
import mm_api.services.admin_unit as svc
|
||||||
|
|
||||||
|
router = APIRouter(tags=["admin-units"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/admin-unit-types")
|
||||||
|
async def list_types(conn: AsyncConnection = Depends(get_conn)):
|
||||||
|
return await svc.list_types(conn)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/admin-units")
|
||||||
|
async def list_units(
|
||||||
|
type_id: int | None = None,
|
||||||
|
active_only: bool = True,
|
||||||
|
conn: AsyncConnection = Depends(get_conn),
|
||||||
|
):
|
||||||
|
return await svc.list_units(conn, type_id, active_only)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/admin-units/{unit_id}")
|
||||||
|
async def get_unit(unit_id: int, conn: AsyncConnection = Depends(get_conn)):
|
||||||
|
unit = await svc.get_unit(conn, unit_id)
|
||||||
|
if not unit:
|
||||||
|
raise HTTPException(404, "Birim bulunamadı")
|
||||||
|
return unit
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/admin-units", status_code=201)
|
||||||
|
async def create_unit(data: AdminUnitCreate, conn: AsyncConnection = Depends(get_conn)):
|
||||||
|
return await svc.create_unit(conn, data)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/admin-units/{unit_id}/close")
|
||||||
|
async def close_unit(unit_id: int, data: AdminUnitClose, conn: AsyncConnection = Depends(get_conn)):
|
||||||
|
unit = await svc.get_unit(conn, unit_id)
|
||||||
|
if not unit:
|
||||||
|
raise HTTPException(404, "Birim bulunamadı")
|
||||||
|
await svc.close_unit(conn, unit_id, data)
|
||||||
|
return await svc.get_unit(conn, unit_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/locations/{location_id}/admin-units")
|
||||||
|
async def assign_unit(location_id: int, data: AdminUnitAssign, conn: AsyncConnection = Depends(get_conn)):
|
||||||
|
await svc.assign_to_location(conn, location_id, data)
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/locations/{location_id}/admin-units/{unit_id}")
|
||||||
|
async def unassign_unit(
|
||||||
|
location_id: int, unit_id: int,
|
||||||
|
valid_until: str,
|
||||||
|
conn: AsyncConnection = Depends(get_conn),
|
||||||
|
):
|
||||||
|
await svc.unassign_from_location(conn, location_id, unit_id, valid_until)
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/locations/{location_id}/admin-units")
|
||||||
|
async def location_units(
|
||||||
|
location_id: int,
|
||||||
|
active_only: bool = True,
|
||||||
|
conn: AsyncConnection = Depends(get_conn),
|
||||||
|
):
|
||||||
|
return await svc.location_units(conn, location_id, active_only)
|
||||||
82
mm_api/routers/auth.py
Normal file
82
mm_api/routers/auth.py
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Request, Security
|
||||||
|
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||||
|
from psycopg import AsyncConnection
|
||||||
|
from mm_api.db import get_conn
|
||||||
|
from mm_api.dependencies import current_user
|
||||||
|
from mm_api.models.auth import RegisterRequest, LoginRequest, RefreshRequest
|
||||||
|
import mm_api.services.auth as svc
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||||
|
bearer = HTTPBearer(auto_error=False)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/register", status_code=201)
|
||||||
|
async def register(data: RegisterRequest, conn: AsyncConnection = Depends(get_conn)):
|
||||||
|
try:
|
||||||
|
return await svc.register(conn, data)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(400, str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/login")
|
||||||
|
async def login(data: LoginRequest, request: Request, conn: AsyncConnection = Depends(get_conn)):
|
||||||
|
ip = request.client.host if request.client else "unknown"
|
||||||
|
user_agent = request.headers.get("user-agent", "")
|
||||||
|
try:
|
||||||
|
return await svc.login(conn, data, ip, user_agent)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(401, str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/refresh")
|
||||||
|
async def refresh(data: RefreshRequest, request: Request, conn: AsyncConnection = Depends(get_conn)):
|
||||||
|
ip = request.client.host if request.client else "unknown"
|
||||||
|
try:
|
||||||
|
return await svc.refresh(conn, data.refresh_token, ip)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(401, str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/logout")
|
||||||
|
async def logout(
|
||||||
|
credentials: HTTPAuthorizationCredentials = Security(bearer),
|
||||||
|
conn: AsyncConnection = Depends(get_conn),
|
||||||
|
):
|
||||||
|
if credentials:
|
||||||
|
await svc.logout(conn, credentials.credentials)
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/logout-all")
|
||||||
|
async def logout_all(
|
||||||
|
user: dict = Depends(current_user),
|
||||||
|
conn: AsyncConnection = Depends(get_conn),
|
||||||
|
):
|
||||||
|
await svc.logout_all(conn, user["id"])
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/me")
|
||||||
|
async def me(user: dict = Depends(current_user)):
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/sessions")
|
||||||
|
async def list_sessions(
|
||||||
|
user: dict = Depends(current_user),
|
||||||
|
conn: AsyncConnection = Depends(get_conn),
|
||||||
|
):
|
||||||
|
return await svc.list_sessions(conn, user["id"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/sessions/{device_id}")
|
||||||
|
async def close_session(
|
||||||
|
device_id: int,
|
||||||
|
user: dict = Depends(current_user),
|
||||||
|
conn: AsyncConnection = Depends(get_conn),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
await svc.logout_device(conn, user["id"], device_id)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(404, str(e))
|
||||||
|
return {"ok": True}
|
||||||
63
mm_api/routers/clients.py
Normal file
63
mm_api/routers/clients.py
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from psycopg import AsyncConnection
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from mm_api.db import get_conn
|
||||||
|
from mm_api.dependencies import current_user
|
||||||
|
import mm_api.services.client as svc
|
||||||
|
import mm_api.services.permission as perm_svc
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/admin/clients", tags=["admin"])
|
||||||
|
|
||||||
|
|
||||||
|
async def _require_superuser(user: dict, conn: AsyncConnection):
|
||||||
|
if not await perm_svc.can(conn, user["id"], "*", "*", is_admin=True):
|
||||||
|
raise HTTPException(403, "Süper yönetici yetkisi gerekli")
|
||||||
|
|
||||||
|
|
||||||
|
class ClientCreate(BaseModel):
|
||||||
|
name: str = Field(..., min_length=2, max_length=100)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
async def list_clients(
|
||||||
|
user: dict = Depends(current_user),
|
||||||
|
conn: AsyncConnection = Depends(get_conn),
|
||||||
|
):
|
||||||
|
await _require_superuser(user, conn)
|
||||||
|
return await svc.list_clients(conn)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", status_code=201)
|
||||||
|
async def create_client(
|
||||||
|
data: ClientCreate,
|
||||||
|
user: dict = Depends(current_user),
|
||||||
|
conn: AsyncConnection = Depends(get_conn),
|
||||||
|
):
|
||||||
|
await _require_superuser(user, conn)
|
||||||
|
return await svc.create_client(conn, data.name)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{client_id}/deactivate")
|
||||||
|
async def deactivate_client(
|
||||||
|
client_id: int,
|
||||||
|
user: dict = Depends(current_user),
|
||||||
|
conn: AsyncConnection = Depends(get_conn),
|
||||||
|
):
|
||||||
|
await _require_superuser(user, conn)
|
||||||
|
try:
|
||||||
|
return await svc.set_active(conn, client_id, False)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(404, str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{client_id}/activate")
|
||||||
|
async def activate_client(
|
||||||
|
client_id: int,
|
||||||
|
user: dict = Depends(current_user),
|
||||||
|
conn: AsyncConnection = Depends(get_conn),
|
||||||
|
):
|
||||||
|
await _require_superuser(user, conn)
|
||||||
|
try:
|
||||||
|
return await svc.set_active(conn, client_id, True)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(404, str(e))
|
||||||
193
mm_api/routers/issues.py
Normal file
193
mm_api/routers/issues.py
Normal file
|
|
@ -0,0 +1,193 @@
|
||||||
|
from typing import Literal
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from psycopg import AsyncConnection
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from mm_api.db import get_conn
|
||||||
|
from mm_api.dependencies import current_user, verified_user
|
||||||
|
import mm_api.services.issue as svc
|
||||||
|
import mm_api.services.permission as perm_svc
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/issues", tags=["issues"])
|
||||||
|
|
||||||
|
|
||||||
|
class IssueCreate(BaseModel):
|
||||||
|
title: str = Field(..., min_length=5, max_length=300)
|
||||||
|
body: str = Field(..., min_length=20)
|
||||||
|
category_id: int
|
||||||
|
location_id: int
|
||||||
|
|
||||||
|
|
||||||
|
class IssueUpdate(BaseModel):
|
||||||
|
title: str | None = Field(None, min_length=5, max_length=300)
|
||||||
|
body: str | None = Field(None, min_length=20)
|
||||||
|
|
||||||
|
|
||||||
|
class StatusUpdate(BaseModel):
|
||||||
|
status: Literal["open", "in_progress", "resolved", "rejected", "duplicate"]
|
||||||
|
|
||||||
|
|
||||||
|
class VoteRequest(BaseModel):
|
||||||
|
vote: Literal["resolved", "ongoing"]
|
||||||
|
|
||||||
|
|
||||||
|
class CommentCreate(BaseModel):
|
||||||
|
body: str = Field(..., min_length=1, max_length=5000)
|
||||||
|
parent_id: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/categories")
|
||||||
|
async def list_categories(
|
||||||
|
parent_id: int | None = Query(None),
|
||||||
|
conn: AsyncConnection = Depends(get_conn),
|
||||||
|
):
|
||||||
|
return await svc.list_categories(conn, parent_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/categories/{category_id}")
|
||||||
|
async def get_category(
|
||||||
|
category_id: int,
|
||||||
|
conn: AsyncConnection = Depends(get_conn),
|
||||||
|
):
|
||||||
|
cat = await svc.get_category(conn, category_id)
|
||||||
|
if not cat:
|
||||||
|
raise HTTPException(404, "Kategori bulunamadı")
|
||||||
|
return cat
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
async def list_issues(
|
||||||
|
location_id: int | None = Query(None),
|
||||||
|
category_id: int | None = Query(None),
|
||||||
|
status: str | None = Query(None),
|
||||||
|
limit: int = Query(20, ge=1, le=100),
|
||||||
|
offset: int = Query(0, ge=0),
|
||||||
|
conn: AsyncConnection = Depends(get_conn),
|
||||||
|
):
|
||||||
|
return await svc.list_issues(conn, location_id, category_id, status, limit=limit, offset=offset)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", status_code=201)
|
||||||
|
async def create_issue(
|
||||||
|
data: IssueCreate,
|
||||||
|
user: dict = Depends(verified_user),
|
||||||
|
conn: AsyncConnection = Depends(get_conn),
|
||||||
|
):
|
||||||
|
if not await perm_svc.can(conn, user["id"], "create", "issue"):
|
||||||
|
raise HTTPException(403, "Sorun bildirme yetkiniz yok")
|
||||||
|
try:
|
||||||
|
return await svc.create_issue(conn, user["id"], data.title, data.body, data.category_id, data.location_id)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(400, str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{issue_id}")
|
||||||
|
async def get_issue(
|
||||||
|
issue_id: int,
|
||||||
|
user: dict = Depends(current_user),
|
||||||
|
conn: AsyncConnection = Depends(get_conn),
|
||||||
|
):
|
||||||
|
issue = await svc.get_issue(conn, issue_id)
|
||||||
|
if not issue:
|
||||||
|
raise HTTPException(404, "Sorun bulunamadı")
|
||||||
|
return issue
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{issue_id}")
|
||||||
|
async def update_issue(
|
||||||
|
issue_id: int,
|
||||||
|
data: IssueUpdate,
|
||||||
|
user: dict = Depends(verified_user),
|
||||||
|
conn: AsyncConnection = Depends(get_conn),
|
||||||
|
):
|
||||||
|
is_admin = await perm_svc.can(conn, user["id"], "update", "issue", is_admin=True)
|
||||||
|
if not is_admin and not await perm_svc.can(conn, user["id"], "update", "issue"):
|
||||||
|
raise HTTPException(403, "Yetersiz yetki")
|
||||||
|
try:
|
||||||
|
return await svc.update_issue(conn, issue_id, user["id"], is_admin, data.title, data.body)
|
||||||
|
except PermissionError as e:
|
||||||
|
raise HTTPException(403, str(e))
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(400, str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{issue_id}/status")
|
||||||
|
async def update_status(
|
||||||
|
issue_id: int,
|
||||||
|
data: StatusUpdate,
|
||||||
|
user: dict = Depends(current_user),
|
||||||
|
conn: AsyncConnection = Depends(get_conn),
|
||||||
|
):
|
||||||
|
if not await perm_svc.can(conn, user["id"], "status", "issue", is_admin=True):
|
||||||
|
raise HTTPException(403, "Yetersiz yetki")
|
||||||
|
try:
|
||||||
|
return await svc.update_status(conn, issue_id, data.status)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(400, str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{issue_id}", status_code=204)
|
||||||
|
async def delete_issue(
|
||||||
|
issue_id: int,
|
||||||
|
user: dict = Depends(current_user),
|
||||||
|
conn: AsyncConnection = Depends(get_conn),
|
||||||
|
):
|
||||||
|
if not await perm_svc.can(conn, user["id"], "delete", "issue", is_admin=True):
|
||||||
|
raise HTTPException(403, "Yetersiz yetki")
|
||||||
|
try:
|
||||||
|
await svc.delete_issue(conn, issue_id)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(400, str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{issue_id}/vote")
|
||||||
|
async def vote(
|
||||||
|
issue_id: int,
|
||||||
|
data: VoteRequest,
|
||||||
|
user: dict = Depends(verified_user),
|
||||||
|
conn: AsyncConnection = Depends(get_conn),
|
||||||
|
):
|
||||||
|
if not await perm_svc.can(conn, user["id"], "create", "vote"):
|
||||||
|
raise HTTPException(403, "Oy kullanma yetkiniz yok")
|
||||||
|
try:
|
||||||
|
return await svc.cast_vote(conn, issue_id, user["id"], data.vote)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(400, str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{issue_id}/comments")
|
||||||
|
async def list_comments(
|
||||||
|
issue_id: int,
|
||||||
|
user: dict = Depends(current_user),
|
||||||
|
conn: AsyncConnection = Depends(get_conn),
|
||||||
|
):
|
||||||
|
return await svc.list_comments(conn, issue_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{issue_id}/comments", status_code=201)
|
||||||
|
async def add_comment(
|
||||||
|
issue_id: int,
|
||||||
|
data: CommentCreate,
|
||||||
|
user: dict = Depends(verified_user),
|
||||||
|
conn: AsyncConnection = Depends(get_conn),
|
||||||
|
):
|
||||||
|
if not await perm_svc.can(conn, user["id"], "create", "comment"):
|
||||||
|
raise HTTPException(403, "Yorum yapma yetkiniz yok")
|
||||||
|
try:
|
||||||
|
return await svc.add_comment(conn, issue_id, user["id"], data.body, data.parent_id)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(400, str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/comments/{comment_id}", status_code=204)
|
||||||
|
async def delete_comment(
|
||||||
|
comment_id: int,
|
||||||
|
user: dict = Depends(current_user),
|
||||||
|
conn: AsyncConnection = Depends(get_conn),
|
||||||
|
):
|
||||||
|
is_admin = await perm_svc.can(conn, user["id"], "delete", "comment", is_admin=True)
|
||||||
|
try:
|
||||||
|
await svc.delete_comment(conn, comment_id, user["id"], is_admin)
|
||||||
|
except PermissionError as e:
|
||||||
|
raise HTTPException(403, str(e))
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(404, str(e))
|
||||||
144
mm_api/routers/kyc.py
Normal file
144
mm_api/routers/kyc.py
Normal file
|
|
@ -0,0 +1,144 @@
|
||||||
|
import uuid
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form
|
||||||
|
from fastapi.responses import Response
|
||||||
|
from psycopg import AsyncConnection
|
||||||
|
from mm_api.db import get_conn
|
||||||
|
from mm_api.dependencies import current_user
|
||||||
|
from pydantic import BaseModel
|
||||||
|
import mm_api.services.kyc as svc
|
||||||
|
import mm_api.services.permission as perm_svc
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/kyc", tags=["kyc"])
|
||||||
|
|
||||||
|
ALLOWED_MIME = {"image/jpeg", "image/png"}
|
||||||
|
MAX_FILE_SIZE = 5 * 1024 * 1024 # 5 MB
|
||||||
|
|
||||||
|
|
||||||
|
class RejectRequest(BaseModel):
|
||||||
|
note: str
|
||||||
|
|
||||||
|
|
||||||
|
async def _require_kyc_perm(action: str, user: dict, conn: AsyncConnection):
|
||||||
|
if not await perm_svc.can(conn, user["id"], action, "kyc", is_admin=True):
|
||||||
|
raise HTTPException(403, "Yetersiz yetki")
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_file(file: UploadFile):
|
||||||
|
if file.content_type not in ALLOWED_MIME:
|
||||||
|
raise HTTPException(400, f"Geçersiz dosya türü: {file.content_type}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/submit", status_code=201)
|
||||||
|
async def submit(
|
||||||
|
tc_kimlik: str = Form(..., min_length=11, max_length=11, pattern=r"^\d{11}$"),
|
||||||
|
id_front: UploadFile = File(...),
|
||||||
|
id_back: UploadFile = File(...),
|
||||||
|
selfie: UploadFile = File(...),
|
||||||
|
user: dict = Depends(current_user),
|
||||||
|
conn: AsyncConnection = Depends(get_conn),
|
||||||
|
):
|
||||||
|
for f in (id_front, id_back, selfie):
|
||||||
|
_validate_file(f)
|
||||||
|
|
||||||
|
req_uuid = str(uuid.uuid4())
|
||||||
|
|
||||||
|
front_data = await id_front.read()
|
||||||
|
back_data = await id_back.read()
|
||||||
|
selfie_data = await selfie.read()
|
||||||
|
|
||||||
|
for data, name in ((front_data, "id_front"), (back_data, "id_back"), (selfie_data, "selfie")):
|
||||||
|
if len(data) > MAX_FILE_SIZE:
|
||||||
|
raise HTTPException(400, f"{name} dosyası 5MB'dan büyük olamaz")
|
||||||
|
|
||||||
|
front_path = svc.save_file_encrypted(req_uuid, "id_front", front_data)
|
||||||
|
back_path = svc.save_file_encrypted(req_uuid, "id_back", back_data)
|
||||||
|
selfie_path = svc.save_file_encrypted(req_uuid, "selfie", selfie_data)
|
||||||
|
|
||||||
|
try:
|
||||||
|
return await svc.submit(conn, user["id"], tc_kimlik, front_path, back_path, selfie_path)
|
||||||
|
except ValueError as e:
|
||||||
|
svc.delete_request_files(req_uuid)
|
||||||
|
raise HTTPException(400, str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/pending")
|
||||||
|
async def list_pending(
|
||||||
|
user: dict = Depends(current_user),
|
||||||
|
conn: AsyncConnection = Depends(get_conn),
|
||||||
|
):
|
||||||
|
await _require_kyc_perm("view", user, conn)
|
||||||
|
return await svc.list_pending(conn)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{request_id}/view-token")
|
||||||
|
async def get_view_token(
|
||||||
|
request_id: int,
|
||||||
|
user: dict = Depends(current_user),
|
||||||
|
conn: AsyncConnection = Depends(get_conn),
|
||||||
|
):
|
||||||
|
await _require_kyc_perm("view", user, conn)
|
||||||
|
req = await svc.get_request(conn, request_id)
|
||||||
|
if not req:
|
||||||
|
raise HTTPException(404, "Başvuru bulunamadı")
|
||||||
|
token = await svc.create_view_token(conn, request_id, user["id"])
|
||||||
|
return {"view_token": token, "expires_in": 600}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/view/{token}/{file_type}")
|
||||||
|
async def view_file(
|
||||||
|
token: str,
|
||||||
|
file_type: str,
|
||||||
|
conn: AsyncConnection = Depends(get_conn),
|
||||||
|
):
|
||||||
|
if file_type not in ("id_front", "id_back", "selfie"):
|
||||||
|
raise HTTPException(400, "Geçersiz dosya türü")
|
||||||
|
|
||||||
|
req = await svc.consume_view_token(conn, token)
|
||||||
|
if not req:
|
||||||
|
raise HTTPException(404, "Geçersiz veya süresi dolmuş token")
|
||||||
|
|
||||||
|
path_key = f"{file_type}_path"
|
||||||
|
data = svc.read_file_encrypted(req[path_key])
|
||||||
|
return Response(content=data, media_type="image/jpeg")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{request_id}/approve")
|
||||||
|
async def approve(
|
||||||
|
request_id: int,
|
||||||
|
user: dict = Depends(current_user),
|
||||||
|
conn: AsyncConnection = Depends(get_conn),
|
||||||
|
):
|
||||||
|
await _require_kyc_perm("approve", user, conn)
|
||||||
|
try:
|
||||||
|
return await svc.approve(conn, request_id, user["id"])
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(400, str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{request_id}/reject")
|
||||||
|
async def reject(
|
||||||
|
request_id: int,
|
||||||
|
data: RejectRequest,
|
||||||
|
user: dict = Depends(current_user),
|
||||||
|
conn: AsyncConnection = Depends(get_conn),
|
||||||
|
):
|
||||||
|
await _require_kyc_perm("reject", user, conn)
|
||||||
|
try:
|
||||||
|
return await svc.reject(conn, request_id, user["id"], data.note)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(400, str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/users/{user_id}/tc")
|
||||||
|
async def get_tc(
|
||||||
|
user_id: int,
|
||||||
|
user: dict = Depends(current_user),
|
||||||
|
conn: AsyncConnection = Depends(get_conn),
|
||||||
|
):
|
||||||
|
"""Adli soruşturma endpoint'i — TC kimlik nosunu döner."""
|
||||||
|
if not await perm_svc.can(conn, user["id"], "view", "kyc", is_admin=True):
|
||||||
|
raise HTTPException(403, "Yetersiz yetki")
|
||||||
|
tc = await svc.get_tc_kimlik(conn, user_id)
|
||||||
|
if tc is None:
|
||||||
|
raise HTTPException(404, "Kimlik bilgisi bulunamadı")
|
||||||
|
return {"user_id": user_id, "tc_kimlik": tc}
|
||||||
66
mm_api/routers/locations.py
Normal file
66
mm_api/routers/locations.py
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from psycopg import AsyncConnection
|
||||||
|
from mm_api.db import get_conn
|
||||||
|
from mm_api.models.location import LocationCreate, LocationRetype, LocationReparent, LocationSplitFrom, LocationMerge
|
||||||
|
import mm_api.services.location as svc
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/locations", tags=["locations"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
async def list_locations(parent_id: int | None = None, conn: AsyncConnection = Depends(get_conn)):
|
||||||
|
return await svc.list_children(conn, parent_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{location_id}")
|
||||||
|
async def get_location(location_id: int, conn: AsyncConnection = Depends(get_conn)):
|
||||||
|
loc = await svc.get(conn, location_id)
|
||||||
|
if not loc:
|
||||||
|
raise HTTPException(404, "Lokasyon bulunamadı")
|
||||||
|
return loc
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", status_code=201)
|
||||||
|
async def create_location(data: LocationCreate, conn: AsyncConnection = Depends(get_conn)):
|
||||||
|
return await svc.create(conn, data)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{location_id}/retype")
|
||||||
|
async def retype_location(location_id: int, data: LocationRetype, conn: AsyncConnection = Depends(get_conn)):
|
||||||
|
loc = await svc.get(conn, location_id)
|
||||||
|
if not loc:
|
||||||
|
raise HTTPException(404, "Lokasyon bulunamadı")
|
||||||
|
await svc.retype(conn, location_id, data)
|
||||||
|
return await svc.get(conn, location_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{location_id}/reparent")
|
||||||
|
async def reparent_location(location_id: int, data: LocationReparent, conn: AsyncConnection = Depends(get_conn)):
|
||||||
|
loc = await svc.get(conn, location_id)
|
||||||
|
if not loc:
|
||||||
|
raise HTTPException(404, "Lokasyon bulunamadı")
|
||||||
|
await svc.reparent(conn, location_id, data)
|
||||||
|
return await svc.get(conn, location_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{location_id}/split-from")
|
||||||
|
async def add_split_from(location_id: int, data: LocationSplitFrom, conn: AsyncConnection = Depends(get_conn)):
|
||||||
|
loc = await svc.get(conn, location_id)
|
||||||
|
if not loc:
|
||||||
|
raise HTTPException(404, "Lokasyon bulunamadı")
|
||||||
|
await svc.add_split_from(conn, location_id, data)
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/merge")
|
||||||
|
async def merge_locations(data: LocationMerge, conn: AsyncConnection = Depends(get_conn)):
|
||||||
|
target = await svc.get(conn, data.target_id)
|
||||||
|
if not target:
|
||||||
|
raise HTTPException(404, "Hedef lokasyon bulunamadı")
|
||||||
|
await svc.merge(conn, data)
|
||||||
|
return {"ok": True, "merged_into": data.target_id}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{location_id}/history")
|
||||||
|
async def location_history(location_id: int, conn: AsyncConnection = Depends(get_conn)):
|
||||||
|
return await svc.history(conn, location_id)
|
||||||
166
mm_api/routers/officials.py
Normal file
166
mm_api/routers/officials.py
Normal file
|
|
@ -0,0 +1,166 @@
|
||||||
|
from typing import Literal
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from psycopg import AsyncConnection
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from mm_api.db import get_conn
|
||||||
|
from mm_api.dependencies import current_user
|
||||||
|
import mm_api.services.official as svc
|
||||||
|
import mm_api.services.permission as perm_svc
|
||||||
|
|
||||||
|
router = APIRouter(tags=["officials"])
|
||||||
|
|
||||||
|
|
||||||
|
async def _require_profile_perm(action: str, user: dict, conn: AsyncConnection):
|
||||||
|
if not await perm_svc.can(conn, user["id"], action, "profile", is_admin=True):
|
||||||
|
raise HTTPException(403, "Yetersiz yetki")
|
||||||
|
|
||||||
|
|
||||||
|
class PersonCreate(BaseModel):
|
||||||
|
first_name: str = Field(..., min_length=1, max_length=100)
|
||||||
|
last_name: str = Field(..., min_length=1, max_length=100)
|
||||||
|
birth_year: int | None = Field(None, ge=1900, le=2010)
|
||||||
|
|
||||||
|
|
||||||
|
class PersonUpdate(BaseModel):
|
||||||
|
first_name: str | None = Field(None, min_length=1, max_length=100)
|
||||||
|
last_name: str | None = Field(None, min_length=1, max_length=100)
|
||||||
|
birth_year: int | None = Field(None, ge=1900, le=2010)
|
||||||
|
|
||||||
|
|
||||||
|
class ElectionCreate(BaseModel):
|
||||||
|
name: str = Field(..., min_length=2, max_length=200)
|
||||||
|
held_at: str # YYYY-MM-DD
|
||||||
|
type: Literal["genel", "yerel", "cumhurbaskanligi", "referandum"]
|
||||||
|
|
||||||
|
|
||||||
|
class OfficialCreate(BaseModel):
|
||||||
|
person_id: int
|
||||||
|
unit_id: int
|
||||||
|
title: str = Field(..., min_length=2, max_length=200)
|
||||||
|
started_at: str # YYYY-MM-DD
|
||||||
|
election_id: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class OfficialClose(BaseModel):
|
||||||
|
ended_at: str # YYYY-MM-DD
|
||||||
|
|
||||||
|
|
||||||
|
# --- Persons ---
|
||||||
|
|
||||||
|
@router.get("/persons")
|
||||||
|
async def list_persons(
|
||||||
|
q: str | None = Query(None),
|
||||||
|
limit: int = Query(20, ge=1, le=100),
|
||||||
|
offset: int = Query(0, ge=0),
|
||||||
|
conn: AsyncConnection = Depends(get_conn),
|
||||||
|
):
|
||||||
|
return await svc.list_persons(conn, q, limit, offset)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/persons/{person_id}")
|
||||||
|
async def get_person(
|
||||||
|
person_id: int,
|
||||||
|
conn: AsyncConnection = Depends(get_conn),
|
||||||
|
):
|
||||||
|
person = await svc.get_person(conn, person_id)
|
||||||
|
if not person:
|
||||||
|
raise HTTPException(404, "Kişi bulunamadı")
|
||||||
|
officials = await svc.get_person_officials(conn, person_id)
|
||||||
|
return {**person, "officials": officials}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/persons", status_code=201)
|
||||||
|
async def create_person(
|
||||||
|
data: PersonCreate,
|
||||||
|
user: dict = Depends(current_user),
|
||||||
|
conn: AsyncConnection = Depends(get_conn),
|
||||||
|
):
|
||||||
|
await _require_profile_perm("create", user, conn)
|
||||||
|
return await svc.create_person(conn, data.first_name, data.last_name, data.birth_year)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/persons/{person_id}")
|
||||||
|
async def update_person(
|
||||||
|
person_id: int,
|
||||||
|
data: PersonUpdate,
|
||||||
|
user: dict = Depends(current_user),
|
||||||
|
conn: AsyncConnection = Depends(get_conn),
|
||||||
|
):
|
||||||
|
await _require_profile_perm("update", user, conn)
|
||||||
|
person = await svc.get_person(conn, person_id)
|
||||||
|
if not person:
|
||||||
|
raise HTTPException(404, "Kişi bulunamadı")
|
||||||
|
try:
|
||||||
|
return await svc.update_person(conn, person_id, data.first_name, data.last_name, data.birth_year)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(400, str(e))
|
||||||
|
|
||||||
|
|
||||||
|
# --- Elections ---
|
||||||
|
|
||||||
|
@router.get("/elections")
|
||||||
|
async def list_elections(conn: AsyncConnection = Depends(get_conn)):
|
||||||
|
return await svc.list_elections(conn)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/elections", status_code=201)
|
||||||
|
async def create_election(
|
||||||
|
data: ElectionCreate,
|
||||||
|
user: dict = Depends(current_user),
|
||||||
|
conn: AsyncConnection = Depends(get_conn),
|
||||||
|
):
|
||||||
|
await _require_profile_perm("create", user, conn)
|
||||||
|
return await svc.create_election(conn, data.name, data.held_at, data.type)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Officials ---
|
||||||
|
|
||||||
|
@router.get("/officials")
|
||||||
|
async def list_officials(
|
||||||
|
person_id: int | None = Query(None),
|
||||||
|
unit_id: int | None = Query(None),
|
||||||
|
active_only: bool = Query(False),
|
||||||
|
limit: int = Query(20, ge=1, le=100),
|
||||||
|
offset: int = Query(0, ge=0),
|
||||||
|
conn: AsyncConnection = Depends(get_conn),
|
||||||
|
):
|
||||||
|
return await svc.list_officials(conn, person_id, unit_id, active_only, limit, offset)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/officials/{official_id}")
|
||||||
|
async def get_official(
|
||||||
|
official_id: int,
|
||||||
|
conn: AsyncConnection = Depends(get_conn),
|
||||||
|
):
|
||||||
|
official = await svc.get_official(conn, official_id)
|
||||||
|
if not official:
|
||||||
|
raise HTTPException(404, "Yetkili kaydı bulunamadı")
|
||||||
|
stats = await svc.get_official_stats(conn, official_id)
|
||||||
|
return {**official, "stats": stats}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/officials", status_code=201)
|
||||||
|
async def create_official(
|
||||||
|
data: OfficialCreate,
|
||||||
|
user: dict = Depends(current_user),
|
||||||
|
conn: AsyncConnection = Depends(get_conn),
|
||||||
|
):
|
||||||
|
await _require_profile_perm("create", user, conn)
|
||||||
|
try:
|
||||||
|
return await svc.create_official(conn, data.person_id, data.unit_id, data.title, data.started_at, data.election_id)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(400, str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/officials/{official_id}/close")
|
||||||
|
async def close_official(
|
||||||
|
official_id: int,
|
||||||
|
data: OfficialClose,
|
||||||
|
user: dict = Depends(current_user),
|
||||||
|
conn: AsyncConnection = Depends(get_conn),
|
||||||
|
):
|
||||||
|
await _require_profile_perm("update", user, conn)
|
||||||
|
try:
|
||||||
|
return await svc.close_official(conn, official_id, data.ended_at)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(400, str(e))
|
||||||
101
mm_api/routers/permissions.py
Normal file
101
mm_api/routers/permissions.py
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from psycopg import AsyncConnection
|
||||||
|
from mm_api.db import get_conn
|
||||||
|
from mm_api.dependencies import current_user
|
||||||
|
from mm_api.models.permission import PermissionGroupCreate, GroupPermissionAssign, UserGroupAssign
|
||||||
|
import mm_api.services.permission as svc
|
||||||
|
|
||||||
|
router = APIRouter(tags=["permissions"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/permissions")
|
||||||
|
async def list_permissions(conn: AsyncConnection = Depends(get_conn)):
|
||||||
|
return await svc.list_permissions(conn)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/permission-groups")
|
||||||
|
async def list_groups(conn: AsyncConnection = Depends(get_conn)):
|
||||||
|
return await svc.list_groups(conn)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/permission-groups/{group_id}")
|
||||||
|
async def get_group(group_id: int, conn: AsyncConnection = Depends(get_conn)):
|
||||||
|
group = await svc.get_group(conn, group_id)
|
||||||
|
if not group:
|
||||||
|
raise HTTPException(404, "Grup bulunamadı")
|
||||||
|
return group
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/permission-groups", status_code=201)
|
||||||
|
async def create_group(
|
||||||
|
data: PermissionGroupCreate,
|
||||||
|
user: dict = Depends(current_user),
|
||||||
|
conn: AsyncConnection = Depends(get_conn),
|
||||||
|
):
|
||||||
|
if not await svc.can(conn, user["id"], "create", "user"):
|
||||||
|
raise HTTPException(403, "Yetersiz yetki")
|
||||||
|
return await svc.create_group(conn, data)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/permission-groups/{group_id}/permissions")
|
||||||
|
async def set_group_permissions(
|
||||||
|
group_id: int,
|
||||||
|
data: GroupPermissionAssign,
|
||||||
|
user: dict = Depends(current_user),
|
||||||
|
conn: AsyncConnection = Depends(get_conn),
|
||||||
|
):
|
||||||
|
if not await svc.can(conn, user["id"], "assign_group", "user"):
|
||||||
|
raise HTTPException(403, "Yetersiz yetki")
|
||||||
|
group = await svc.get_group(conn, group_id)
|
||||||
|
if not group:
|
||||||
|
raise HTTPException(404, "Grup bulunamadı")
|
||||||
|
await svc.set_group_permissions(conn, group_id, data)
|
||||||
|
return await svc.group_permissions(conn, group_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/permission-groups/{group_id}/permissions")
|
||||||
|
async def get_group_permissions(group_id: int, conn: AsyncConnection = Depends(get_conn)):
|
||||||
|
return await svc.group_permissions(conn, group_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/user-groups")
|
||||||
|
async def assign_user_to_group(
|
||||||
|
data: UserGroupAssign,
|
||||||
|
user: dict = Depends(current_user),
|
||||||
|
conn: AsyncConnection = Depends(get_conn),
|
||||||
|
):
|
||||||
|
if not await svc.can(conn, user["id"], "assign_group", "user"):
|
||||||
|
raise HTTPException(403, "Yetersiz yetki")
|
||||||
|
await svc.assign_user_to_group(conn, data, granted_by=user["id"])
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/user-groups/{user_id}/{group_id}")
|
||||||
|
async def remove_user_from_group(
|
||||||
|
user_id: int,
|
||||||
|
group_id: int,
|
||||||
|
user: dict = Depends(current_user),
|
||||||
|
conn: AsyncConnection = Depends(get_conn),
|
||||||
|
):
|
||||||
|
if not await svc.can(conn, user["id"], "assign_group", "user"):
|
||||||
|
raise HTTPException(403, "Yetersiz yetki")
|
||||||
|
await svc.remove_user_from_group(conn, user_id, group_id)
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/users/{user_id}/groups")
|
||||||
|
async def get_user_groups(
|
||||||
|
user_id: int,
|
||||||
|
user: dict = Depends(current_user),
|
||||||
|
conn: AsyncConnection = Depends(get_conn),
|
||||||
|
):
|
||||||
|
return await svc.user_groups(conn, user_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/users/{user_id}/permissions")
|
||||||
|
async def get_user_permissions(
|
||||||
|
user_id: int,
|
||||||
|
user: dict = Depends(current_user),
|
||||||
|
conn: AsyncConnection = Depends(get_conn),
|
||||||
|
):
|
||||||
|
return await svc.user_permissions(conn, user_id)
|
||||||
0
mm_api/services/__init__.py
Normal file
0
mm_api/services/__init__.py
Normal file
111
mm_api/services/admin_unit.py
Normal file
111
mm_api/services/admin_unit.py
Normal file
|
|
@ -0,0 +1,111 @@
|
||||||
|
from psycopg import AsyncConnection
|
||||||
|
from mm_api.models.admin_unit import AdminUnitCreate, AdminUnitAssign, AdminUnitClose
|
||||||
|
|
||||||
|
|
||||||
|
async def list_types(conn: AsyncConnection) -> list[dict]:
|
||||||
|
rows = await (await conn.execute(
|
||||||
|
"SELECT id, name, slug, description FROM administrative_unit_types ORDER BY name"
|
||||||
|
)).fetchall()
|
||||||
|
return [{"id": r[0], "name": r[1], "slug": r[2], "description": r[3]} for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
async def list_units(conn: AsyncConnection, type_id: int | None, active_only: bool) -> list[dict]:
|
||||||
|
where = []
|
||||||
|
params = []
|
||||||
|
if type_id:
|
||||||
|
where.append("u.type_id = %s")
|
||||||
|
params.append(type_id)
|
||||||
|
if active_only:
|
||||||
|
where.append("u.is_active = TRUE")
|
||||||
|
clause = ("WHERE " + " AND ".join(where)) if where else ""
|
||||||
|
rows = await (await conn.execute(
|
||||||
|
f"""SELECT u.id, u.type_id, t.name, u.name, u.established_at, u.abolished_at, u.is_active
|
||||||
|
FROM administrative_units u
|
||||||
|
JOIN administrative_unit_types t ON t.id = u.type_id
|
||||||
|
{clause}
|
||||||
|
ORDER BY u.name""",
|
||||||
|
params
|
||||||
|
)).fetchall()
|
||||||
|
return [_row(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
async def get_unit(conn: AsyncConnection, unit_id: int) -> dict | None:
|
||||||
|
row = await (await conn.execute(
|
||||||
|
"""SELECT u.id, u.type_id, t.name, u.name, u.established_at, u.abolished_at, u.is_active
|
||||||
|
FROM administrative_units u
|
||||||
|
JOIN administrative_unit_types t ON t.id = u.type_id
|
||||||
|
WHERE u.id = %s""",
|
||||||
|
(unit_id,)
|
||||||
|
)).fetchone()
|
||||||
|
return _row(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
async def create_unit(conn: AsyncConnection, data: AdminUnitCreate) -> dict:
|
||||||
|
row = await (await conn.execute(
|
||||||
|
"INSERT INTO administrative_units (type_id, name, established_at) VALUES (%s, %s, %s) RETURNING id",
|
||||||
|
(data.type_id, data.name, data.established_at)
|
||||||
|
)).fetchone()
|
||||||
|
await conn.commit()
|
||||||
|
return await get_unit(conn, row[0])
|
||||||
|
|
||||||
|
|
||||||
|
async def close_unit(conn: AsyncConnection, unit_id: int, data: AdminUnitClose):
|
||||||
|
"""Birimi kapat: abolished_at yaz, bağlı lokasyonların valid_until'ını güncelle."""
|
||||||
|
await conn.execute(
|
||||||
|
"UPDATE administrative_units SET abolished_at = %s, is_active = FALSE WHERE id = %s",
|
||||||
|
(data.abolished_at, unit_id)
|
||||||
|
)
|
||||||
|
await conn.execute(
|
||||||
|
"UPDATE location_administrative_units SET valid_until = %s "
|
||||||
|
"WHERE unit_id = %s AND valid_until IS NULL",
|
||||||
|
(data.valid_until, unit_id)
|
||||||
|
)
|
||||||
|
await conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def assign_to_location(conn: AsyncConnection, location_id: int, data: AdminUnitAssign):
|
||||||
|
"""Lokasyona idari birim ata."""
|
||||||
|
await conn.execute(
|
||||||
|
"INSERT INTO location_administrative_units (location_id, unit_id, valid_from, valid_until) "
|
||||||
|
"VALUES (%s, %s, %s, %s) ON CONFLICT (location_id, unit_id, valid_from) DO NOTHING",
|
||||||
|
(location_id, data.unit_id, data.valid_from, data.valid_until)
|
||||||
|
)
|
||||||
|
await conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def unassign_from_location(conn: AsyncConnection, location_id: int, unit_id: int, valid_until: str):
|
||||||
|
"""Lokasyon-birim bağlantısını kapat."""
|
||||||
|
await conn.execute(
|
||||||
|
"UPDATE location_administrative_units SET valid_until = %s "
|
||||||
|
"WHERE location_id = %s AND unit_id = %s AND valid_until IS NULL",
|
||||||
|
(valid_until, location_id, unit_id)
|
||||||
|
)
|
||||||
|
await conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def location_units(conn: AsyncConnection, location_id: int, active_only: bool) -> list[dict]:
|
||||||
|
"""Bir lokasyona bağlı idari birimleri listele."""
|
||||||
|
where = "WHERE lau.location_id = %s"
|
||||||
|
if active_only:
|
||||||
|
where += " AND (lau.valid_until IS NULL OR lau.valid_until > NOW())"
|
||||||
|
rows = await (await conn.execute(
|
||||||
|
f"""SELECT u.id, u.type_id, t.name, u.name, u.established_at, u.abolished_at, u.is_active,
|
||||||
|
lau.valid_from, lau.valid_until
|
||||||
|
FROM location_administrative_units lau
|
||||||
|
JOIN administrative_units u ON u.id = lau.unit_id
|
||||||
|
JOIN administrative_unit_types t ON t.id = u.type_id
|
||||||
|
{where}
|
||||||
|
ORDER BY lau.valid_from DESC""",
|
||||||
|
(location_id,)
|
||||||
|
)).fetchall()
|
||||||
|
return [
|
||||||
|
{**_row(r[:7]), "valid_from": r[7], "valid_until": r[8]}
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _row(r) -> dict:
|
||||||
|
return {
|
||||||
|
"id": r[0], "type_id": r[1], "type_name": r[2], "name": r[3],
|
||||||
|
"established_at": r[4], "abolished_at": r[5], "is_active": r[6],
|
||||||
|
}
|
||||||
226
mm_api/services/auth.py
Normal file
226
mm_api/services/auth.py
Normal file
|
|
@ -0,0 +1,226 @@
|
||||||
|
import hashlib
|
||||||
|
import secrets
|
||||||
|
from datetime import datetime, timezone, timedelta
|
||||||
|
from argon2 import PasswordHasher
|
||||||
|
from argon2.exceptions import VerifyMismatchError, VerificationError, InvalidHashError
|
||||||
|
from psycopg import AsyncConnection
|
||||||
|
from mm_api.models.auth import RegisterRequest, LoginRequest
|
||||||
|
|
||||||
|
ph = PasswordHasher(time_cost=2, memory_cost=65536, parallelism=2)
|
||||||
|
|
||||||
|
ACCESS_TTL = timedelta(minutes=15)
|
||||||
|
REFRESH_TTL = timedelta(days=30)
|
||||||
|
|
||||||
|
|
||||||
|
def _hash_token(token: str) -> str:
|
||||||
|
return hashlib.sha256(token.encode()).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _gen_token() -> str:
|
||||||
|
return secrets.token_urlsafe(32)
|
||||||
|
|
||||||
|
|
||||||
|
async def register(conn: AsyncConnection, data: RegisterRequest) -> dict:
|
||||||
|
existing = await (await conn.execute(
|
||||||
|
"SELECT id FROM users WHERE email = %s", (data.email,)
|
||||||
|
)).fetchone()
|
||||||
|
if existing:
|
||||||
|
raise ValueError("Bu e-posta zaten kayıtlı")
|
||||||
|
|
||||||
|
password_hash = ph.hash(data.password)
|
||||||
|
row = await (await conn.execute(
|
||||||
|
"INSERT INTO users (email, password_hash) VALUES (%s, %s) RETURNING id, email, kyc_status, created_at",
|
||||||
|
(data.email, password_hash)
|
||||||
|
)).fetchone()
|
||||||
|
await conn.commit()
|
||||||
|
return {"id": row[0], "email": row[1], "kyc_status": row[2], "created_at": row[3]}
|
||||||
|
|
||||||
|
|
||||||
|
async def login(conn: AsyncConnection, data: LoginRequest, ip: str, user_agent: str) -> dict:
|
||||||
|
row = await (await conn.execute(
|
||||||
|
"SELECT id, password_hash, is_active FROM users WHERE email = %s", (data.email,)
|
||||||
|
)).fetchone()
|
||||||
|
if not row:
|
||||||
|
raise ValueError("E-posta veya şifre hatalı")
|
||||||
|
|
||||||
|
user_id, password_hash, is_active = row
|
||||||
|
if not is_active:
|
||||||
|
raise ValueError("Hesap askıya alınmış")
|
||||||
|
|
||||||
|
try:
|
||||||
|
ph.verify(password_hash, data.password)
|
||||||
|
except (VerifyMismatchError, VerificationError, InvalidHashError):
|
||||||
|
raise ValueError("E-posta veya şifre hatalı")
|
||||||
|
|
||||||
|
if ph.check_needs_rehash(password_hash):
|
||||||
|
new_hash = ph.hash(data.password)
|
||||||
|
await conn.execute("UPDATE users SET password_hash = %s WHERE id = %s", (new_hash, user_id))
|
||||||
|
|
||||||
|
device_id = await (await conn.execute(
|
||||||
|
"""INSERT INTO user_devices (user_id, device_name, user_agent, last_ip)
|
||||||
|
VALUES (%s, %s, %s, %s) RETURNING id""",
|
||||||
|
(user_id, data.device_name, user_agent, ip)
|
||||||
|
)).fetchone()
|
||||||
|
device_id = device_id[0]
|
||||||
|
|
||||||
|
tokens = await _issue_tokens(conn, user_id, device_id)
|
||||||
|
await conn.execute("UPDATE users SET last_login_at = NOW() WHERE id = %s", (user_id,))
|
||||||
|
await conn.commit()
|
||||||
|
return tokens
|
||||||
|
|
||||||
|
|
||||||
|
async def refresh(conn: AsyncConnection, refresh_token: str, ip: str) -> dict:
|
||||||
|
token_hash = _hash_token(refresh_token)
|
||||||
|
row = await (await conn.execute(
|
||||||
|
"SELECT id, user_id, device_id, used_at, revoked, expires_at FROM refresh_tokens WHERE token_hash = %s",
|
||||||
|
(token_hash,)
|
||||||
|
)).fetchone()
|
||||||
|
|
||||||
|
if not row:
|
||||||
|
raise ValueError("Geçersiz token")
|
||||||
|
|
||||||
|
rt_id, user_id, device_id, used_at, revoked, expires_at = row
|
||||||
|
|
||||||
|
if used_at is not None or revoked:
|
||||||
|
await conn.execute(
|
||||||
|
"UPDATE refresh_tokens SET revoked = TRUE WHERE user_id = %s", (user_id,)
|
||||||
|
)
|
||||||
|
await conn.execute(
|
||||||
|
"DELETE FROM access_tokens WHERE user_id = %s", (user_id,)
|
||||||
|
)
|
||||||
|
await conn.commit()
|
||||||
|
raise ValueError("Token zaten kullanılmış, tüm oturumlar kapatıldı")
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
if expires_at < now:
|
||||||
|
raise ValueError("Token süresi dolmuş")
|
||||||
|
|
||||||
|
await conn.execute(
|
||||||
|
"UPDATE refresh_tokens SET used_at = NOW() WHERE id = %s", (rt_id,)
|
||||||
|
)
|
||||||
|
await conn.execute(
|
||||||
|
"DELETE FROM access_tokens WHERE user_id = %s AND device_id = %s", (user_id, device_id)
|
||||||
|
)
|
||||||
|
# IP güncelle
|
||||||
|
await conn.execute(
|
||||||
|
"UPDATE user_devices SET last_ip = %s, last_seen_at = NOW() WHERE id = %s", (ip, device_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
tokens = await _issue_tokens(conn, user_id, device_id)
|
||||||
|
await conn.commit()
|
||||||
|
return tokens
|
||||||
|
|
||||||
|
|
||||||
|
async def logout(conn: AsyncConnection, access_token: str):
|
||||||
|
token_hash = _hash_token(access_token)
|
||||||
|
row = await (await conn.execute(
|
||||||
|
"SELECT user_id, device_id FROM access_tokens WHERE token_hash = %s", (token_hash,)
|
||||||
|
)).fetchone()
|
||||||
|
if row:
|
||||||
|
user_id, device_id = row
|
||||||
|
await conn.execute("DELETE FROM access_tokens WHERE token_hash = %s", (token_hash,))
|
||||||
|
await conn.execute(
|
||||||
|
"UPDATE refresh_tokens SET revoked = TRUE WHERE user_id = %s AND device_id = %s AND revoked = FALSE",
|
||||||
|
(user_id, device_id)
|
||||||
|
)
|
||||||
|
await conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def logout_all(conn: AsyncConnection, user_id: int):
|
||||||
|
"""Kullanıcının tüm cihazlarındaki oturumları kapatır."""
|
||||||
|
await conn.execute("DELETE FROM access_tokens WHERE user_id = %s", (user_id,))
|
||||||
|
await conn.execute("UPDATE refresh_tokens SET revoked = TRUE WHERE user_id = %s AND revoked = FALSE", (user_id,))
|
||||||
|
await conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def logout_device(conn: AsyncConnection, user_id: int, device_id: int):
|
||||||
|
"""Belirli bir cihazın oturumunu kapatır. Sadece kendi cihazını kapatabilir."""
|
||||||
|
row = await (await conn.execute(
|
||||||
|
"SELECT id FROM user_devices WHERE id = %s AND user_id = %s", (device_id, user_id)
|
||||||
|
)).fetchone()
|
||||||
|
if not row:
|
||||||
|
raise ValueError("Cihaz bulunamadı")
|
||||||
|
await conn.execute("DELETE FROM access_tokens WHERE user_id = %s AND device_id = %s", (user_id, device_id))
|
||||||
|
await conn.execute(
|
||||||
|
"UPDATE refresh_tokens SET revoked = TRUE WHERE user_id = %s AND device_id = %s AND revoked = FALSE",
|
||||||
|
(user_id, device_id)
|
||||||
|
)
|
||||||
|
await conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def get_current_user(conn: AsyncConnection, access_token: str) -> dict | None:
|
||||||
|
token_hash = _hash_token(access_token)
|
||||||
|
row = await (await conn.execute(
|
||||||
|
"""SELECT u.id, u.email, u.kyc_status, u.is_active, at.expires_at, at.device_id
|
||||||
|
FROM access_tokens at
|
||||||
|
JOIN users u ON u.id = at.user_id
|
||||||
|
WHERE at.token_hash = %s""",
|
||||||
|
(token_hash,)
|
||||||
|
)).fetchone()
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
if row[4] < datetime.now(timezone.utc):
|
||||||
|
return None
|
||||||
|
if not row[3]:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# last_seen_at güncelle (fire-and-forget, hata olsa da önemli değil)
|
||||||
|
await conn.execute(
|
||||||
|
"UPDATE user_devices SET last_seen_at = NOW() WHERE id = %s", (row[5],)
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"id": row[0], "email": row[1], "kyc_status": row[2]}
|
||||||
|
|
||||||
|
|
||||||
|
async def list_sessions(conn: AsyncConnection, user_id: int) -> list[dict]:
|
||||||
|
"""Kullanıcının aktif oturumlarını (cihazlarını) döner."""
|
||||||
|
rows = await (await conn.execute(
|
||||||
|
"""SELECT d.id, d.device_name, d.user_agent, d.last_ip, d.last_seen_at, d.created_at,
|
||||||
|
EXISTS(SELECT 1 FROM access_tokens at WHERE at.device_id = d.id AND at.expires_at > NOW()) AS has_active_token
|
||||||
|
FROM user_devices d
|
||||||
|
WHERE d.user_id = %s
|
||||||
|
AND EXISTS(
|
||||||
|
SELECT 1 FROM refresh_tokens rt
|
||||||
|
WHERE rt.device_id = d.id AND rt.revoked = FALSE AND rt.expires_at > NOW()
|
||||||
|
)
|
||||||
|
ORDER BY d.last_seen_at DESC""",
|
||||||
|
(user_id,)
|
||||||
|
)).fetchall()
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"device_id": r[0],
|
||||||
|
"device_name": r[1],
|
||||||
|
"user_agent": r[2],
|
||||||
|
"last_ip": str(r[3]) if r[3] else None,
|
||||||
|
"last_seen_at": r[4],
|
||||||
|
"created_at": r[5],
|
||||||
|
"is_active": r[6],
|
||||||
|
}
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
async def cleanup_expired_tokens(conn: AsyncConnection):
|
||||||
|
"""Süresi dolmuş token'ları temizler. Uygulama başlangıcında çağrılır."""
|
||||||
|
await conn.execute("SELECT cleanup_expired_tokens()")
|
||||||
|
await conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def _issue_tokens(conn: AsyncConnection, user_id: int, device_id: int) -> dict:
|
||||||
|
access_token = _gen_token()
|
||||||
|
refresh_token = _gen_token()
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
await conn.execute(
|
||||||
|
"INSERT INTO access_tokens (user_id, device_id, token_hash, expires_at) VALUES (%s, %s, %s, %s)",
|
||||||
|
(user_id, device_id, _hash_token(access_token), now + ACCESS_TTL)
|
||||||
|
)
|
||||||
|
await conn.execute(
|
||||||
|
"INSERT INTO refresh_tokens (user_id, device_id, token_hash, expires_at) VALUES (%s, %s, %s, %s)",
|
||||||
|
(user_id, device_id, _hash_token(refresh_token), now + REFRESH_TTL)
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"access_token": access_token,
|
||||||
|
"refresh_token": refresh_token,
|
||||||
|
"expires_in": int(ACCESS_TTL.total_seconds()),
|
||||||
|
}
|
||||||
50
mm_api/services/client.py
Normal file
50
mm_api/services/client.py
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
import hashlib
|
||||||
|
import secrets
|
||||||
|
from psycopg import AsyncConnection
|
||||||
|
|
||||||
|
|
||||||
|
def _hash_secret(secret: str) -> str:
|
||||||
|
return hashlib.sha256(secret.encode()).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def generate_secret() -> str:
|
||||||
|
return secrets.token_urlsafe(32)
|
||||||
|
|
||||||
|
|
||||||
|
async def verify_client(conn: AsyncConnection, secret: str) -> bool:
|
||||||
|
h = _hash_secret(secret)
|
||||||
|
row = await (await conn.execute(
|
||||||
|
"SELECT id FROM api_clients WHERE secret_hash = %s AND is_active = TRUE",
|
||||||
|
(h,)
|
||||||
|
)).fetchone()
|
||||||
|
return row is not None
|
||||||
|
|
||||||
|
|
||||||
|
async def create_client(conn: AsyncConnection, name: str) -> dict:
|
||||||
|
secret = generate_secret()
|
||||||
|
h = _hash_secret(secret)
|
||||||
|
row = await (await conn.execute(
|
||||||
|
"INSERT INTO api_clients (name, secret_hash) VALUES (%s, %s) RETURNING id, name, created_at",
|
||||||
|
(name, h)
|
||||||
|
)).fetchone()
|
||||||
|
await conn.commit()
|
||||||
|
# secret yalnızca bir kez döner, sonra erişilemez
|
||||||
|
return {"id": row[0], "name": row[1], "secret": secret, "created_at": row[2]}
|
||||||
|
|
||||||
|
|
||||||
|
async def list_clients(conn: AsyncConnection) -> list[dict]:
|
||||||
|
rows = await (await conn.execute(
|
||||||
|
"SELECT id, name, is_active, created_at FROM api_clients ORDER BY created_at DESC"
|
||||||
|
)).fetchall()
|
||||||
|
return [{"id": r[0], "name": r[1], "is_active": r[2], "created_at": r[3]} for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
async def set_active(conn: AsyncConnection, client_id: int, is_active: bool) -> dict:
|
||||||
|
row = await (await conn.execute(
|
||||||
|
"UPDATE api_clients SET is_active = %s WHERE id = %s RETURNING id, name, is_active, created_at",
|
||||||
|
(is_active, client_id)
|
||||||
|
)).fetchone()
|
||||||
|
if not row:
|
||||||
|
raise ValueError("Client bulunamadı")
|
||||||
|
await conn.commit()
|
||||||
|
return {"id": row[0], "name": row[1], "is_active": row[2], "created_at": row[3]}
|
||||||
275
mm_api/services/issue.py
Normal file
275
mm_api/services/issue.py
Normal file
|
|
@ -0,0 +1,275 @@
|
||||||
|
from datetime import datetime, timezone, timedelta
|
||||||
|
from psycopg import AsyncConnection
|
||||||
|
|
||||||
|
EDIT_GRACE_PERIOD = timedelta(minutes=15)
|
||||||
|
|
||||||
|
|
||||||
|
async def list_categories(conn: AsyncConnection, parent_id: int | None = None) -> list[dict]:
|
||||||
|
rows = await (await conn.execute(
|
||||||
|
"""SELECT id, parent_id, name, slug, icon
|
||||||
|
FROM issue_categories
|
||||||
|
WHERE (parent_id = %s OR (%s IS NULL AND parent_id IS NULL))
|
||||||
|
ORDER BY name""",
|
||||||
|
(parent_id, parent_id)
|
||||||
|
)).fetchall()
|
||||||
|
return [{"id": r[0], "parent_id": r[1], "name": r[2], "slug": r[3], "icon": r[4]} for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
async def get_category(conn: AsyncConnection, category_id: int) -> dict | None:
|
||||||
|
row = await (await conn.execute(
|
||||||
|
"SELECT id, parent_id, name, slug, icon FROM issue_categories WHERE id = %s",
|
||||||
|
(category_id,)
|
||||||
|
)).fetchone()
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
return {"id": row[0], "parent_id": row[1], "name": row[2], "slug": row[3], "icon": row[4]}
|
||||||
|
|
||||||
|
|
||||||
|
async def create_issue(
|
||||||
|
conn: AsyncConnection,
|
||||||
|
reporter_id: int,
|
||||||
|
title: str,
|
||||||
|
body: str,
|
||||||
|
category_id: int,
|
||||||
|
location_id: int,
|
||||||
|
) -> dict:
|
||||||
|
cat = await get_category(conn, category_id)
|
||||||
|
if not cat:
|
||||||
|
raise ValueError("Kategori bulunamadı")
|
||||||
|
|
||||||
|
loc = await (await conn.execute(
|
||||||
|
"SELECT id FROM locations WHERE id = %s", (location_id,)
|
||||||
|
)).fetchone()
|
||||||
|
if not loc:
|
||||||
|
raise ValueError("Konum bulunamadı")
|
||||||
|
|
||||||
|
row = await (await conn.execute(
|
||||||
|
"""INSERT INTO issues (title, body, category_id, location_id, reporter_id)
|
||||||
|
VALUES (%s, %s, %s, %s, %s)
|
||||||
|
RETURNING id, status, created_at""",
|
||||||
|
(title, body, category_id, location_id, reporter_id)
|
||||||
|
)).fetchone()
|
||||||
|
await conn.commit()
|
||||||
|
return {"id": row[0], "status": row[1], "created_at": row[2]}
|
||||||
|
|
||||||
|
|
||||||
|
async def get_issue(conn: AsyncConnection, issue_id: int) -> dict | None:
|
||||||
|
row = await (await conn.execute(
|
||||||
|
"""SELECT i.id, i.title, i.body, i.status, i.created_at, i.updated_at,
|
||||||
|
i.reporter_id, i.category_id, i.location_id,
|
||||||
|
ic.name AS category_name, ic.slug AS category_slug,
|
||||||
|
l.name AS location_name,
|
||||||
|
(SELECT COUNT(*) FROM issue_votes v WHERE v.issue_id = i.id AND v.vote = 'resolved') AS votes_resolved,
|
||||||
|
(SELECT COUNT(*) FROM issue_votes v WHERE v.issue_id = i.id AND v.vote = 'ongoing') AS votes_ongoing,
|
||||||
|
(SELECT COUNT(*) FROM issue_comments c WHERE c.issue_id = i.id AND NOT c.is_deleted) AS comment_count
|
||||||
|
FROM issues i
|
||||||
|
JOIN issue_categories ic ON ic.id = i.category_id
|
||||||
|
JOIN locations l ON l.id = i.location_id
|
||||||
|
WHERE i.id = %s""",
|
||||||
|
(issue_id,)
|
||||||
|
)).fetchone()
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"id": row[0], "title": row[1], "body": row[2], "status": row[3],
|
||||||
|
"created_at": row[4], "updated_at": row[5],
|
||||||
|
"reporter_id": row[6], "category_id": row[7], "location_id": row[8],
|
||||||
|
"category_name": row[9], "category_slug": row[10],
|
||||||
|
"location_name": row[11],
|
||||||
|
"votes": {"resolved": row[12], "ongoing": row[13]},
|
||||||
|
"comment_count": row[14],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def list_issues(
|
||||||
|
conn: AsyncConnection,
|
||||||
|
location_id: int | None = None,
|
||||||
|
category_id: int | None = None,
|
||||||
|
status: str | None = None,
|
||||||
|
reporter_id: int | None = None,
|
||||||
|
limit: int = 20,
|
||||||
|
offset: int = 0,
|
||||||
|
) -> list[dict]:
|
||||||
|
filters = []
|
||||||
|
params: list = []
|
||||||
|
if location_id:
|
||||||
|
filters.append("i.location_id = %s"); params.append(location_id)
|
||||||
|
if category_id:
|
||||||
|
filters.append("i.category_id = %s"); params.append(category_id)
|
||||||
|
if status:
|
||||||
|
filters.append("i.status = %s"); params.append(status)
|
||||||
|
if reporter_id:
|
||||||
|
filters.append("i.reporter_id = %s"); params.append(reporter_id)
|
||||||
|
|
||||||
|
where = ("WHERE " + " AND ".join(filters)) if filters else ""
|
||||||
|
params += [limit, offset]
|
||||||
|
|
||||||
|
rows = await (await conn.execute(
|
||||||
|
f"""SELECT i.id, i.title, i.status, i.created_at,
|
||||||
|
ic.name AS category_name, l.name AS location_name,
|
||||||
|
(SELECT COUNT(*) FROM issue_votes v WHERE v.issue_id = i.id) AS vote_count
|
||||||
|
FROM issues i
|
||||||
|
JOIN issue_categories ic ON ic.id = i.category_id
|
||||||
|
JOIN locations l ON l.id = i.location_id
|
||||||
|
{where}
|
||||||
|
ORDER BY i.created_at DESC
|
||||||
|
LIMIT %s OFFSET %s""",
|
||||||
|
params
|
||||||
|
)).fetchall()
|
||||||
|
return [
|
||||||
|
{"id": r[0], "title": r[1], "status": r[2], "created_at": r[3],
|
||||||
|
"category_name": r[4], "location_name": r[5], "vote_count": r[6]}
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
async def update_issue(
|
||||||
|
conn: AsyncConnection,
|
||||||
|
issue_id: int,
|
||||||
|
user_id: int,
|
||||||
|
is_admin: bool,
|
||||||
|
title: str | None = None,
|
||||||
|
body: str | None = None,
|
||||||
|
) -> dict:
|
||||||
|
row = await (await conn.execute(
|
||||||
|
"SELECT reporter_id, created_at, status FROM issues WHERE id = %s", (issue_id,)
|
||||||
|
)).fetchone()
|
||||||
|
if not row:
|
||||||
|
raise ValueError("Sorun bulunamadı")
|
||||||
|
|
||||||
|
reporter_id, created_at, status = row
|
||||||
|
|
||||||
|
if not is_admin:
|
||||||
|
if reporter_id != user_id:
|
||||||
|
raise PermissionError("Bu sorunu düzenleme yetkiniz yok")
|
||||||
|
age = datetime.now(timezone.utc) - created_at
|
||||||
|
if age > EDIT_GRACE_PERIOD:
|
||||||
|
raise PermissionError("Düzenleme süresi doldu (15 dakika)")
|
||||||
|
|
||||||
|
updates = []
|
||||||
|
params = []
|
||||||
|
if title is not None:
|
||||||
|
updates.append("title = %s"); params.append(title)
|
||||||
|
if body is not None:
|
||||||
|
updates.append("body = %s"); params.append(body)
|
||||||
|
if not updates:
|
||||||
|
raise ValueError("Güncellenecek alan yok")
|
||||||
|
|
||||||
|
updates.append("updated_at = NOW()")
|
||||||
|
params.append(issue_id)
|
||||||
|
await conn.execute(
|
||||||
|
f"UPDATE issues SET {', '.join(updates)} WHERE id = %s", params
|
||||||
|
)
|
||||||
|
await conn.commit()
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
async def update_status(
|
||||||
|
conn: AsyncConnection,
|
||||||
|
issue_id: int,
|
||||||
|
new_status: str,
|
||||||
|
) -> dict:
|
||||||
|
row = await (await conn.execute(
|
||||||
|
"SELECT id FROM issues WHERE id = %s", (issue_id,)
|
||||||
|
)).fetchone()
|
||||||
|
if not row:
|
||||||
|
raise ValueError("Sorun bulunamadı")
|
||||||
|
await conn.execute(
|
||||||
|
"UPDATE issues SET status = %s, updated_at = NOW() WHERE id = %s",
|
||||||
|
(new_status, issue_id)
|
||||||
|
)
|
||||||
|
await conn.commit()
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_issue(conn: AsyncConnection, issue_id: int) -> dict:
|
||||||
|
row = await (await conn.execute(
|
||||||
|
"SELECT id FROM issues WHERE id = %s", (issue_id,)
|
||||||
|
)).fetchone()
|
||||||
|
if not row:
|
||||||
|
raise ValueError("Sorun bulunamadı")
|
||||||
|
await conn.execute("DELETE FROM issues WHERE id = %s", (issue_id,))
|
||||||
|
await conn.commit()
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
async def cast_vote(conn: AsyncConnection, issue_id: int, user_id: int, vote: str) -> dict:
|
||||||
|
row = await (await conn.execute(
|
||||||
|
"SELECT id FROM issues WHERE id = %s", (issue_id,)
|
||||||
|
)).fetchone()
|
||||||
|
if not row:
|
||||||
|
raise ValueError("Sorun bulunamadı")
|
||||||
|
await conn.execute(
|
||||||
|
"""INSERT INTO issue_votes (issue_id, user_id, vote)
|
||||||
|
VALUES (%s, %s, %s)
|
||||||
|
ON CONFLICT (issue_id, user_id) DO UPDATE SET vote = EXCLUDED.vote""",
|
||||||
|
(issue_id, user_id, vote)
|
||||||
|
)
|
||||||
|
await conn.commit()
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
async def list_comments(conn: AsyncConnection, issue_id: int) -> list[dict]:
|
||||||
|
rows = await (await conn.execute(
|
||||||
|
"""SELECT c.id, c.parent_id, c.user_id, c.body, c.is_deleted, c.created_at, c.updated_at
|
||||||
|
FROM issue_comments c
|
||||||
|
WHERE c.issue_id = %s
|
||||||
|
ORDER BY c.created_at ASC""",
|
||||||
|
(issue_id,)
|
||||||
|
)).fetchall()
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": r[0], "parent_id": r[1], "user_id": r[2],
|
||||||
|
"body": "[silindi]" if r[4] else r[3],
|
||||||
|
"is_deleted": r[4], "created_at": r[5], "updated_at": r[6],
|
||||||
|
}
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
async def add_comment(
|
||||||
|
conn: AsyncConnection,
|
||||||
|
issue_id: int,
|
||||||
|
user_id: int,
|
||||||
|
body: str,
|
||||||
|
parent_id: int | None = None,
|
||||||
|
) -> dict:
|
||||||
|
row = await (await conn.execute(
|
||||||
|
"SELECT id FROM issues WHERE id = %s", (issue_id,)
|
||||||
|
)).fetchone()
|
||||||
|
if not row:
|
||||||
|
raise ValueError("Sorun bulunamadı")
|
||||||
|
|
||||||
|
if parent_id:
|
||||||
|
parent = await (await conn.execute(
|
||||||
|
"SELECT id FROM issue_comments WHERE id = %s AND issue_id = %s AND NOT is_deleted",
|
||||||
|
(parent_id, issue_id)
|
||||||
|
)).fetchone()
|
||||||
|
if not parent:
|
||||||
|
raise ValueError("Yanıt verilen yorum bulunamadı")
|
||||||
|
|
||||||
|
result = await (await conn.execute(
|
||||||
|
"""INSERT INTO issue_comments (issue_id, user_id, body, parent_id)
|
||||||
|
VALUES (%s, %s, %s, %s)
|
||||||
|
RETURNING id, created_at""",
|
||||||
|
(issue_id, user_id, body, parent_id)
|
||||||
|
)).fetchone()
|
||||||
|
await conn.commit()
|
||||||
|
return {"id": result[0], "created_at": result[1]}
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_comment(conn: AsyncConnection, comment_id: int, user_id: int, is_admin: bool) -> dict:
|
||||||
|
row = await (await conn.execute(
|
||||||
|
"SELECT user_id FROM issue_comments WHERE id = %s AND NOT is_deleted",
|
||||||
|
(comment_id,)
|
||||||
|
)).fetchone()
|
||||||
|
if not row:
|
||||||
|
raise ValueError("Yorum bulunamadı")
|
||||||
|
if not is_admin and row[0] != user_id:
|
||||||
|
raise PermissionError("Bu yorumu silme yetkiniz yok")
|
||||||
|
await conn.execute(
|
||||||
|
"UPDATE issue_comments SET is_deleted = TRUE, updated_at = NOW() WHERE id = %s",
|
||||||
|
(comment_id,)
|
||||||
|
)
|
||||||
|
await conn.commit()
|
||||||
|
return {"ok": True}
|
||||||
224
mm_api/services/kyc.py
Normal file
224
mm_api/services/kyc.py
Normal file
|
|
@ -0,0 +1,224 @@
|
||||||
|
import hashlib
|
||||||
|
import os
|
||||||
|
import secrets
|
||||||
|
import shutil
|
||||||
|
from datetime import datetime, timezone, timedelta
|
||||||
|
from pathlib import Path
|
||||||
|
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||||
|
from psycopg import AsyncConnection
|
||||||
|
|
||||||
|
UPLOAD_DIR = Path(os.getenv("UPLOAD_DIR", "/opt/mm/uploads/kyc"))
|
||||||
|
VIEW_TOKEN_TTL = timedelta(minutes=10)
|
||||||
|
|
||||||
|
# AES-256-GCM — 32 byte key, hex encoded in env
|
||||||
|
_raw_key = bytes.fromhex(os.getenv("KYC_ENCRYPTION_KEY", "0" * 64))
|
||||||
|
_aesgcm = AESGCM(_raw_key)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Şifreleme yardımcıları ---
|
||||||
|
|
||||||
|
def _encrypt(data: bytes) -> bytes:
|
||||||
|
nonce = secrets.token_bytes(12)
|
||||||
|
return nonce + _aesgcm.encrypt(nonce, data, None)
|
||||||
|
|
||||||
|
|
||||||
|
def _decrypt(data: bytes) -> bytes:
|
||||||
|
return _aesgcm.decrypt(data[:12], data[12:], None)
|
||||||
|
|
||||||
|
|
||||||
|
def _hash_tc(tc: str) -> str:
|
||||||
|
return hashlib.sha256(tc.encode()).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
# --- Dosya işlemleri ---
|
||||||
|
|
||||||
|
def _request_dir(request_uuid: str) -> Path:
|
||||||
|
path = UPLOAD_DIR / request_uuid
|
||||||
|
path.mkdir(parents=True, exist_ok=True)
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def save_file_encrypted(request_uuid: str, filename: str, data: bytes) -> str:
|
||||||
|
"""Dosyayı şifreli kaydeder, göreli path döner."""
|
||||||
|
enc = _encrypt(data)
|
||||||
|
path = _request_dir(request_uuid) / (filename + ".enc")
|
||||||
|
path.write_bytes(enc)
|
||||||
|
return str(path)
|
||||||
|
|
||||||
|
|
||||||
|
def read_file_encrypted(path: str) -> bytes:
|
||||||
|
return _decrypt(Path(path).read_bytes())
|
||||||
|
|
||||||
|
|
||||||
|
def delete_request_files(request_uuid: str):
|
||||||
|
d = UPLOAD_DIR / request_uuid
|
||||||
|
if d.exists():
|
||||||
|
shutil.rmtree(d)
|
||||||
|
|
||||||
|
|
||||||
|
# --- DB işlemleri ---
|
||||||
|
|
||||||
|
async def submit(
|
||||||
|
conn: AsyncConnection,
|
||||||
|
user_id: int,
|
||||||
|
tc_kimlik: str,
|
||||||
|
front_path: str,
|
||||||
|
back_path: str,
|
||||||
|
selfie_path: str,
|
||||||
|
) -> dict:
|
||||||
|
# Aynı TC ile başka kayıtlı kullanıcı var mı?
|
||||||
|
tc_hash = _hash_tc(tc_kimlik)
|
||||||
|
existing = await (await conn.execute(
|
||||||
|
"SELECT id FROM users WHERE tc_kimlik_hash = %s AND id != %s", (tc_hash, user_id)
|
||||||
|
)).fetchone()
|
||||||
|
if existing:
|
||||||
|
raise ValueError("Bu kimlik numarası başka bir hesaba kayıtlı")
|
||||||
|
|
||||||
|
# Bekleyen başvuru var mı?
|
||||||
|
pending = await (await conn.execute(
|
||||||
|
"SELECT id FROM verification_requests WHERE user_id = %s AND status = 'pending'", (user_id,)
|
||||||
|
)).fetchone()
|
||||||
|
if pending:
|
||||||
|
raise ValueError("Bekleyen bir başvurunuz zaten mevcut")
|
||||||
|
|
||||||
|
tc_enc = _encrypt(tc_kimlik.encode())
|
||||||
|
|
||||||
|
await conn.execute(
|
||||||
|
"UPDATE users SET tc_kimlik_enc = %s, tc_kimlik_hash = %s, kyc_status = 'pending' WHERE id = %s",
|
||||||
|
(tc_enc, tc_hash, user_id)
|
||||||
|
)
|
||||||
|
row = await (await conn.execute(
|
||||||
|
"""INSERT INTO verification_requests (user_id, id_front_path, id_back_path, selfie_path)
|
||||||
|
VALUES (%s, %s, %s, %s) RETURNING id, submitted_at""",
|
||||||
|
(user_id, front_path, back_path, selfie_path)
|
||||||
|
)).fetchone()
|
||||||
|
await conn.commit()
|
||||||
|
return {"id": row[0], "status": "pending", "submitted_at": row[1]}
|
||||||
|
|
||||||
|
|
||||||
|
async def list_pending(conn: AsyncConnection) -> list[dict]:
|
||||||
|
rows = await (await conn.execute(
|
||||||
|
"""SELECT vr.id, vr.user_id, u.email, vr.submitted_at, vr.rejection_count
|
||||||
|
FROM verification_requests vr
|
||||||
|
JOIN users u ON u.id = vr.user_id
|
||||||
|
WHERE vr.status = 'pending'
|
||||||
|
ORDER BY vr.submitted_at ASC"""
|
||||||
|
)).fetchall()
|
||||||
|
return [
|
||||||
|
{"id": r[0], "user_id": r[1], "email": r[2], "submitted_at": r[3], "rejection_count": r[4]}
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
async def get_request(conn: AsyncConnection, request_id: int) -> dict | None:
|
||||||
|
row = await (await conn.execute(
|
||||||
|
"""SELECT vr.id, vr.user_id, u.email, vr.status, vr.submitted_at,
|
||||||
|
vr.id_front_path, vr.id_back_path, vr.selfie_path,
|
||||||
|
vr.review_note, vr.reviewed_at, vr.rejection_count
|
||||||
|
FROM verification_requests vr
|
||||||
|
JOIN users u ON u.id = vr.user_id
|
||||||
|
WHERE vr.id = %s""",
|
||||||
|
(request_id,)
|
||||||
|
)).fetchone()
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"id": row[0], "user_id": row[1], "email": row[2], "status": row[3],
|
||||||
|
"submitted_at": row[4], "id_front_path": row[5], "id_back_path": row[6],
|
||||||
|
"selfie_path": row[7], "review_note": row[8], "reviewed_at": row[9],
|
||||||
|
"rejection_count": row[10],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def create_view_token(conn: AsyncConnection, request_id: int, admin_id: int) -> str:
|
||||||
|
token = secrets.token_urlsafe(32)
|
||||||
|
expires_at = datetime.now(timezone.utc) + VIEW_TOKEN_TTL
|
||||||
|
await conn.execute(
|
||||||
|
"INSERT INTO kyc_view_tokens (token, request_id, created_by, expires_at) VALUES (%s, %s, %s, %s)",
|
||||||
|
(token, request_id, admin_id, expires_at)
|
||||||
|
)
|
||||||
|
await conn.commit()
|
||||||
|
return token
|
||||||
|
|
||||||
|
|
||||||
|
async def consume_view_token(conn: AsyncConnection, token: str) -> dict | None:
|
||||||
|
"""Token'ı tüketir (tek kullanımlık), geçerliyse request bilgilerini döner."""
|
||||||
|
row = await (await conn.execute(
|
||||||
|
"""SELECT request_id, expires_at, used_at
|
||||||
|
FROM kyc_view_tokens WHERE token = %s""",
|
||||||
|
(token,)
|
||||||
|
)).fetchone()
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
request_id, expires_at, used_at = row
|
||||||
|
if used_at is not None:
|
||||||
|
return None
|
||||||
|
if expires_at < datetime.now(timezone.utc):
|
||||||
|
return None
|
||||||
|
await conn.execute(
|
||||||
|
"UPDATE kyc_view_tokens SET used_at = NOW() WHERE token = %s", (token,)
|
||||||
|
)
|
||||||
|
await conn.commit()
|
||||||
|
return await get_request(conn, request_id)
|
||||||
|
|
||||||
|
|
||||||
|
async def approve(conn: AsyncConnection, request_id: int, reviewed_by: int) -> dict:
|
||||||
|
req = await get_request(conn, request_id)
|
||||||
|
if not req:
|
||||||
|
raise ValueError("Başvuru bulunamadı")
|
||||||
|
if req["status"] != "pending":
|
||||||
|
raise ValueError("Bu başvuru zaten işlenmiş")
|
||||||
|
|
||||||
|
await conn.execute(
|
||||||
|
"""UPDATE verification_requests
|
||||||
|
SET status = 'approved', reviewed_by = %s, reviewed_at = NOW()
|
||||||
|
WHERE id = %s""",
|
||||||
|
(reviewed_by, request_id)
|
||||||
|
)
|
||||||
|
await conn.execute(
|
||||||
|
"UPDATE users SET kyc_status = 'verified', kyc_verified_at = NOW() WHERE id = %s",
|
||||||
|
(req["user_id"],)
|
||||||
|
)
|
||||||
|
await conn.commit()
|
||||||
|
|
||||||
|
delete_request_files(_request_uuid(req))
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
async def reject(conn: AsyncConnection, request_id: int, reviewed_by: int, note: str) -> dict:
|
||||||
|
req = await get_request(conn, request_id)
|
||||||
|
if not req:
|
||||||
|
raise ValueError("Başvuru bulunamadı")
|
||||||
|
if req["status"] != "pending":
|
||||||
|
raise ValueError("Bu başvuru zaten işlenmiş")
|
||||||
|
|
||||||
|
await conn.execute(
|
||||||
|
"""UPDATE verification_requests
|
||||||
|
SET status = 'rejected', reviewed_by = %s, reviewed_at = NOW(),
|
||||||
|
review_note = %s, rejection_count = rejection_count + 1
|
||||||
|
WHERE id = %s""",
|
||||||
|
(reviewed_by, note, request_id)
|
||||||
|
)
|
||||||
|
await conn.execute(
|
||||||
|
"UPDATE users SET kyc_status = 'rejected' WHERE id = %s",
|
||||||
|
(req["user_id"],)
|
||||||
|
)
|
||||||
|
await conn.commit()
|
||||||
|
|
||||||
|
delete_request_files(_request_uuid(req))
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
async def get_tc_kimlik(conn: AsyncConnection, user_id: int) -> str | None:
|
||||||
|
"""Adli soruşturma: kullanıcının TC kimlik nosunu döner."""
|
||||||
|
row = await (await conn.execute(
|
||||||
|
"SELECT tc_kimlik_enc FROM users WHERE id = %s", (user_id,)
|
||||||
|
)).fetchone()
|
||||||
|
if not row or not row[0]:
|
||||||
|
return None
|
||||||
|
return _decrypt(bytes(row[0])).decode()
|
||||||
|
|
||||||
|
|
||||||
|
def _request_uuid(req: dict) -> str:
|
||||||
|
# Dosya path'inden UUID klasör adını çıkar
|
||||||
|
return Path(req["id_front_path"]).parent.name
|
||||||
124
mm_api/services/location.py
Normal file
124
mm_api/services/location.py
Normal file
|
|
@ -0,0 +1,124 @@
|
||||||
|
"""
|
||||||
|
Lokasyon iş mantığı — her operasyon history snapshot'ı atar ve
|
||||||
|
ilgili değişiklik tablolarını otomatik günceller.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from datetime import datetime
|
||||||
|
from psycopg import AsyncConnection
|
||||||
|
from mm_api.models.location import LocationCreate, LocationRetype, LocationReparent, LocationSplitFrom, LocationMerge
|
||||||
|
|
||||||
|
|
||||||
|
async def _snapshot(conn: AsyncConnection, location_id: int, change_reason: str, changed_by: int | None):
|
||||||
|
row = await (await conn.execute(
|
||||||
|
"SELECT id, parent_id, name, slug, type, latitude, longitude, is_active, created_at, updated_at "
|
||||||
|
"FROM locations WHERE id = %s",
|
||||||
|
(location_id,)
|
||||||
|
)).fetchone()
|
||||||
|
if not row:
|
||||||
|
return
|
||||||
|
snap = {
|
||||||
|
"id": row[0], "parent_id": row[1], "name": row[2], "slug": row[3],
|
||||||
|
"type": row[4], "latitude": row[5], "longitude": row[6],
|
||||||
|
"is_active": row[7],
|
||||||
|
}
|
||||||
|
await conn.execute(
|
||||||
|
"INSERT INTO location_history (location_id, snapshot, change_reason, changed_by) VALUES (%s, %s, %s, %s)",
|
||||||
|
(location_id, json.dumps(snap), change_reason, changed_by)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def create(conn: AsyncConnection, data: LocationCreate) -> dict:
|
||||||
|
row = await (await conn.execute(
|
||||||
|
"INSERT INTO locations (parent_id, name, slug, type, latitude, longitude) "
|
||||||
|
"VALUES (%s, %s, %s, %s, %s, %s) RETURNING *",
|
||||||
|
(data.parent_id, data.name, data.slug, data.type.value, data.latitude, data.longitude)
|
||||||
|
)).fetchone()
|
||||||
|
await conn.commit()
|
||||||
|
return _row_to_dict(row)
|
||||||
|
|
||||||
|
|
||||||
|
async def get(conn: AsyncConnection, location_id: int) -> dict | None:
|
||||||
|
row = await (await conn.execute(
|
||||||
|
"SELECT * FROM locations WHERE id = %s", (location_id,)
|
||||||
|
)).fetchone()
|
||||||
|
return _row_to_dict(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
async def list_children(conn: AsyncConnection, parent_id: int | None) -> list[dict]:
|
||||||
|
if parent_id is None:
|
||||||
|
rows = await (await conn.execute(
|
||||||
|
"SELECT * FROM locations WHERE parent_id IS NULL ORDER BY name"
|
||||||
|
)).fetchall()
|
||||||
|
else:
|
||||||
|
rows = await (await conn.execute(
|
||||||
|
"SELECT * FROM locations WHERE parent_id = %s ORDER BY name", (parent_id,)
|
||||||
|
)).fetchall()
|
||||||
|
return [_row_to_dict(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
async def retype(conn: AsyncConnection, location_id: int, data: LocationRetype):
|
||||||
|
await _snapshot(conn, location_id, data.change_reason, data.changed_by)
|
||||||
|
await conn.execute(
|
||||||
|
"UPDATE locations SET type = %s, updated_at = NOW() WHERE id = %s",
|
||||||
|
(data.new_type.value, location_id)
|
||||||
|
)
|
||||||
|
await conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def reparent(conn: AsyncConnection, location_id: int, data: LocationReparent):
|
||||||
|
await _snapshot(conn, location_id, data.change_reason, data.changed_by)
|
||||||
|
await conn.execute(
|
||||||
|
"UPDATE locations SET parent_id = %s, updated_at = NOW() WHERE id = %s",
|
||||||
|
(data.new_parent_id, location_id)
|
||||||
|
)
|
||||||
|
await conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def add_split_from(conn: AsyncConnection, location_id: int, data: LocationSplitFrom):
|
||||||
|
await conn.execute(
|
||||||
|
"INSERT INTO location_splits (location_id, source_id, effective_at, notes) "
|
||||||
|
"VALUES (%s, %s, %s, %s) ON CONFLICT DO NOTHING",
|
||||||
|
(location_id, data.source_id, data.effective_at, data.notes)
|
||||||
|
)
|
||||||
|
await conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def merge(conn: AsyncConnection, data: LocationMerge):
|
||||||
|
"""
|
||||||
|
source_ids içindeki lokasyonları pasifleştirir, target_id'ye birleşme kaydı ekler,
|
||||||
|
her kaynak için location_history snapshot'ı atar.
|
||||||
|
"""
|
||||||
|
for src_id in data.source_ids:
|
||||||
|
await _snapshot(conn, src_id, data.change_reason, data.changed_by)
|
||||||
|
await conn.execute(
|
||||||
|
"UPDATE locations SET is_active = FALSE, updated_at = NOW() WHERE id = %s",
|
||||||
|
(src_id,)
|
||||||
|
)
|
||||||
|
await conn.execute(
|
||||||
|
"INSERT INTO location_merges (source_id, target_id, effective_at, notes) "
|
||||||
|
"VALUES (%s, %s, %s, %s) ON CONFLICT DO NOTHING",
|
||||||
|
(src_id, data.target_id, data.effective_at, data.notes)
|
||||||
|
)
|
||||||
|
await conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def history(conn: AsyncConnection, location_id: int) -> list[dict]:
|
||||||
|
rows = await (await conn.execute(
|
||||||
|
"SELECT id, location_id, snapshot, change_reason, changed_by, changed_at "
|
||||||
|
"FROM location_history WHERE location_id = %s ORDER BY changed_at",
|
||||||
|
(location_id,)
|
||||||
|
)).fetchall()
|
||||||
|
return [
|
||||||
|
{"id": r[0], "location_id": r[1], "snapshot": r[2],
|
||||||
|
"change_reason": r[3], "changed_by": r[4], "changed_at": r[5]}
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _row_to_dict(row) -> dict:
|
||||||
|
return {
|
||||||
|
"id": row[0], "parent_id": row[1], "name": row[2], "slug": row[3],
|
||||||
|
"type": row[4], "latitude": row[5], "longitude": row[6],
|
||||||
|
"is_active": row[7], "created_at": row[8], "updated_at": row[9],
|
||||||
|
}
|
||||||
188
mm_api/services/official.py
Normal file
188
mm_api/services/official.py
Normal file
|
|
@ -0,0 +1,188 @@
|
||||||
|
from psycopg import AsyncConnection
|
||||||
|
|
||||||
|
|
||||||
|
# --- Persons ---
|
||||||
|
|
||||||
|
async def list_persons(conn: AsyncConnection, q: str | None = None, limit: int = 20, offset: int = 0) -> list[dict]:
|
||||||
|
if q:
|
||||||
|
rows = await (await conn.execute(
|
||||||
|
"""SELECT id, first_name, last_name, birth_year, user_id
|
||||||
|
FROM persons
|
||||||
|
WHERE first_name ILIKE %s OR last_name ILIKE %s
|
||||||
|
ORDER BY last_name, first_name LIMIT %s OFFSET %s""",
|
||||||
|
(f"%{q}%", f"%{q}%", limit, offset)
|
||||||
|
)).fetchall()
|
||||||
|
else:
|
||||||
|
rows = await (await conn.execute(
|
||||||
|
"""SELECT id, first_name, last_name, birth_year, user_id
|
||||||
|
FROM persons ORDER BY last_name, first_name LIMIT %s OFFSET %s""",
|
||||||
|
(limit, offset)
|
||||||
|
)).fetchall()
|
||||||
|
return [_person_row(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
async def get_person(conn: AsyncConnection, person_id: int) -> dict | None:
|
||||||
|
row = await (await conn.execute(
|
||||||
|
"SELECT id, first_name, last_name, birth_year, user_id FROM persons WHERE id = %s",
|
||||||
|
(person_id,)
|
||||||
|
)).fetchone()
|
||||||
|
return _person_row(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
async def create_person(conn: AsyncConnection, first_name: str, last_name: str, birth_year: int | None) -> dict:
|
||||||
|
row = await (await conn.execute(
|
||||||
|
"INSERT INTO persons (first_name, last_name, birth_year) VALUES (%s, %s, %s) RETURNING id",
|
||||||
|
(first_name, last_name, birth_year)
|
||||||
|
)).fetchone()
|
||||||
|
await conn.commit()
|
||||||
|
return await get_person(conn, row[0])
|
||||||
|
|
||||||
|
|
||||||
|
async def update_person(conn: AsyncConnection, person_id: int, first_name: str | None, last_name: str | None, birth_year: int | None) -> dict:
|
||||||
|
updates, params = [], []
|
||||||
|
if first_name is not None:
|
||||||
|
updates.append("first_name = %s"); params.append(first_name)
|
||||||
|
if last_name is not None:
|
||||||
|
updates.append("last_name = %s"); params.append(last_name)
|
||||||
|
if birth_year is not None:
|
||||||
|
updates.append("birth_year = %s"); params.append(birth_year)
|
||||||
|
if not updates:
|
||||||
|
raise ValueError("Güncellenecek alan yok")
|
||||||
|
params.append(person_id)
|
||||||
|
await conn.execute(f"UPDATE persons SET {', '.join(updates)} WHERE id = %s", params)
|
||||||
|
await conn.commit()
|
||||||
|
return await get_person(conn, person_id)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Elections ---
|
||||||
|
|
||||||
|
async def list_elections(conn: AsyncConnection) -> list[dict]:
|
||||||
|
rows = await (await conn.execute(
|
||||||
|
"SELECT id, name, held_at, type FROM elections ORDER BY held_at DESC"
|
||||||
|
)).fetchall()
|
||||||
|
return [{"id": r[0], "name": r[1], "held_at": r[2], "type": r[3]} for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
async def create_election(conn: AsyncConnection, name: str, held_at: str, type_: str) -> dict:
|
||||||
|
row = await (await conn.execute(
|
||||||
|
"INSERT INTO elections (name, held_at, type) VALUES (%s, %s, %s) RETURNING id, name, held_at, type",
|
||||||
|
(name, held_at, type_)
|
||||||
|
)).fetchone()
|
||||||
|
await conn.commit()
|
||||||
|
return {"id": row[0], "name": row[1], "held_at": row[2], "type": row[3]}
|
||||||
|
|
||||||
|
|
||||||
|
# --- Officials ---
|
||||||
|
|
||||||
|
async def list_officials(
|
||||||
|
conn: AsyncConnection,
|
||||||
|
person_id: int | None = None,
|
||||||
|
unit_id: int | None = None,
|
||||||
|
active_only: bool = False,
|
||||||
|
limit: int = 20,
|
||||||
|
offset: int = 0,
|
||||||
|
) -> list[dict]:
|
||||||
|
filters, params = [], []
|
||||||
|
if person_id:
|
||||||
|
filters.append("o.person_id = %s"); params.append(person_id)
|
||||||
|
if unit_id:
|
||||||
|
filters.append("o.unit_id = %s"); params.append(unit_id)
|
||||||
|
if active_only:
|
||||||
|
filters.append("o.ended_at IS NULL")
|
||||||
|
where = ("WHERE " + " AND ".join(filters)) if filters else ""
|
||||||
|
params += [limit, offset]
|
||||||
|
rows = await (await conn.execute(
|
||||||
|
f"""SELECT o.id, o.person_id, p.first_name, p.last_name,
|
||||||
|
o.unit_id, au.name AS unit_name,
|
||||||
|
o.title, o.started_at, o.ended_at, o.election_id
|
||||||
|
FROM officials o
|
||||||
|
JOIN persons p ON p.id = o.person_id
|
||||||
|
JOIN administrative_units au ON au.id = o.unit_id
|
||||||
|
{where}
|
||||||
|
ORDER BY o.started_at DESC LIMIT %s OFFSET %s""",
|
||||||
|
params
|
||||||
|
)).fetchall()
|
||||||
|
return [_official_row(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
async def get_official(conn: AsyncConnection, official_id: int) -> dict | None:
|
||||||
|
row = await (await conn.execute(
|
||||||
|
"""SELECT o.id, o.person_id, p.first_name, p.last_name,
|
||||||
|
o.unit_id, au.name AS unit_name,
|
||||||
|
o.title, o.started_at, o.ended_at, o.election_id
|
||||||
|
FROM officials o
|
||||||
|
JOIN persons p ON p.id = o.person_id
|
||||||
|
JOIN administrative_units au ON au.id = o.unit_id
|
||||||
|
WHERE o.id = %s""",
|
||||||
|
(official_id,)
|
||||||
|
)).fetchone()
|
||||||
|
return _official_row(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
async def create_official(
|
||||||
|
conn: AsyncConnection,
|
||||||
|
person_id: int,
|
||||||
|
unit_id: int,
|
||||||
|
title: str,
|
||||||
|
started_at: str,
|
||||||
|
election_id: int | None,
|
||||||
|
) -> dict:
|
||||||
|
person = await (await conn.execute("SELECT id FROM persons WHERE id = %s", (person_id,))).fetchone()
|
||||||
|
if not person:
|
||||||
|
raise ValueError("Kişi bulunamadı")
|
||||||
|
unit = await (await conn.execute("SELECT id FROM administrative_units WHERE id = %s", (unit_id,))).fetchone()
|
||||||
|
if not unit:
|
||||||
|
raise ValueError("İdari birim bulunamadı")
|
||||||
|
|
||||||
|
row = await (await conn.execute(
|
||||||
|
"""INSERT INTO officials (person_id, unit_id, title, started_at, election_id)
|
||||||
|
VALUES (%s, %s, %s, %s, %s) RETURNING id""",
|
||||||
|
(person_id, unit_id, title, started_at, election_id)
|
||||||
|
)).fetchone()
|
||||||
|
await conn.commit()
|
||||||
|
return await get_official(conn, row[0])
|
||||||
|
|
||||||
|
|
||||||
|
async def close_official(conn: AsyncConnection, official_id: int, ended_at: str) -> dict:
|
||||||
|
row = await (await conn.execute("SELECT id, ended_at FROM officials WHERE id = %s", (official_id,))).fetchone()
|
||||||
|
if not row:
|
||||||
|
raise ValueError("Yetkili kaydı bulunamadı")
|
||||||
|
if row[1] is not None:
|
||||||
|
raise ValueError("Bu görev zaten kapatılmış")
|
||||||
|
await conn.execute("UPDATE officials SET ended_at = %s WHERE id = %s", (ended_at, official_id))
|
||||||
|
await conn.commit()
|
||||||
|
return await get_official(conn, official_id)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_person_officials(conn: AsyncConnection, person_id: int) -> list[dict]:
|
||||||
|
return await list_officials(conn, person_id=person_id, limit=100)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_official_stats(conn: AsyncConnection, official_id: int) -> list[dict]:
|
||||||
|
rows = await (await conn.execute(
|
||||||
|
"""SELECT period_start, period_end, open_at_start, new_during,
|
||||||
|
resolved, rejected, score, computed_at
|
||||||
|
FROM official_stats WHERE official_id = %s ORDER BY period_start DESC""",
|
||||||
|
(official_id,)
|
||||||
|
)).fetchall()
|
||||||
|
return [
|
||||||
|
{"period_start": r[0], "period_end": r[1], "open_at_start": r[2],
|
||||||
|
"new_during": r[3], "resolved": r[4], "rejected": r[5],
|
||||||
|
"score": float(r[6]) if r[6] else None, "computed_at": r[7]}
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _person_row(r) -> dict:
|
||||||
|
return {"id": r[0], "first_name": r[1], "last_name": r[2], "birth_year": r[3], "user_id": r[4]}
|
||||||
|
|
||||||
|
|
||||||
|
def _official_row(r) -> dict:
|
||||||
|
return {
|
||||||
|
"id": r[0], "person_id": r[1],
|
||||||
|
"person_name": f"{r[2]} {r[3]}",
|
||||||
|
"unit_id": r[4], "unit_name": r[5],
|
||||||
|
"title": r[6], "started_at": r[7],
|
||||||
|
"ended_at": r[8], "election_id": r[9],
|
||||||
|
"is_active": r[8] is None,
|
||||||
|
}
|
||||||
135
mm_api/services/permission.py
Normal file
135
mm_api/services/permission.py
Normal file
|
|
@ -0,0 +1,135 @@
|
||||||
|
from psycopg import AsyncConnection
|
||||||
|
from mm_api.models.permission import PermissionGroupCreate, GroupPermissionAssign, UserGroupAssign
|
||||||
|
|
||||||
|
|
||||||
|
async def can(conn: AsyncConnection, user_id: int, action: str, module: str, is_admin: bool = False) -> bool:
|
||||||
|
"""
|
||||||
|
Kullanıcının belirtilen modül+eylem için yetkisi var mı?
|
||||||
|
is_admin=True → admin kapsamı (herhangi bir içerik); False → yalnızca kendi içeriği.
|
||||||
|
Süper kullanıcı grubundaysa is_admin değerinden bağımsız direkt True.
|
||||||
|
scope=FALSE olan izinler her iki durumda da geçerlidir (kendi içeriği hem admin hem normal kullanıcıya açık).
|
||||||
|
"""
|
||||||
|
row = await (await conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM user_groups ug
|
||||||
|
JOIN permission_groups pg ON pg.id = ug.group_id
|
||||||
|
LEFT JOIN group_permissions gp ON gp.group_id = pg.id
|
||||||
|
LEFT JOIN permissions p ON p.id = gp.permission_id
|
||||||
|
WHERE ug.user_id = %s
|
||||||
|
AND (
|
||||||
|
pg.is_superuser = TRUE
|
||||||
|
OR (
|
||||||
|
p.module IN (%s, '*')
|
||||||
|
AND p.action IN (%s, '*')
|
||||||
|
AND (p.scope = %s OR p.scope = FALSE)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
(user_id, module, action, is_admin)
|
||||||
|
)).fetchone()
|
||||||
|
return row[0] if row else False
|
||||||
|
|
||||||
|
|
||||||
|
async def user_permissions(conn: AsyncConnection, user_id: int) -> list[dict]:
|
||||||
|
"""Kullanıcının sahip olduğu tüm izinleri döner."""
|
||||||
|
rows = await (await conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT DISTINCT p.module, p.action, p.description, pg.is_superuser
|
||||||
|
FROM user_groups ug
|
||||||
|
JOIN permission_groups pg ON pg.id = ug.group_id
|
||||||
|
LEFT JOIN group_permissions gp ON gp.group_id = pg.id
|
||||||
|
LEFT JOIN permissions p ON p.id = gp.permission_id
|
||||||
|
WHERE ug.user_id = %s
|
||||||
|
ORDER BY p.module, p.action
|
||||||
|
""",
|
||||||
|
(user_id,)
|
||||||
|
)).fetchall()
|
||||||
|
if any(r[3] for r in rows): # süper kullanıcı grubundaysa
|
||||||
|
return [{"module": "*", "action": "*", "description": "Tüm izinler", "is_superuser": True}]
|
||||||
|
return [{"module": r[0], "action": r[1], "description": r[2], "is_superuser": False} for r in rows if r[0]]
|
||||||
|
|
||||||
|
|
||||||
|
async def list_permissions(conn: AsyncConnection) -> list[dict]:
|
||||||
|
rows = await (await conn.execute(
|
||||||
|
"SELECT id, module, action, description FROM permissions ORDER BY module, action"
|
||||||
|
)).fetchall()
|
||||||
|
return [{"id": r[0], "module": r[1], "action": r[2], "description": r[3]} for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
async def list_groups(conn: AsyncConnection) -> list[dict]:
|
||||||
|
rows = await (await conn.execute(
|
||||||
|
"SELECT id, name, description, is_superuser, created_at FROM permission_groups ORDER BY name"
|
||||||
|
)).fetchall()
|
||||||
|
return [_group_row(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
async def get_group(conn: AsyncConnection, group_id: int) -> dict | None:
|
||||||
|
row = await (await conn.execute(
|
||||||
|
"SELECT id, name, description, is_superuser, created_at FROM permission_groups WHERE id = %s",
|
||||||
|
(group_id,)
|
||||||
|
)).fetchone()
|
||||||
|
return _group_row(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
async def create_group(conn: AsyncConnection, data: PermissionGroupCreate) -> dict:
|
||||||
|
row = await (await conn.execute(
|
||||||
|
"INSERT INTO permission_groups (name, description, is_superuser) VALUES (%s, %s, %s) RETURNING id",
|
||||||
|
(data.name, data.description, data.is_superuser)
|
||||||
|
)).fetchone()
|
||||||
|
await conn.commit()
|
||||||
|
return await get_group(conn, row[0])
|
||||||
|
|
||||||
|
|
||||||
|
async def set_group_permissions(conn: AsyncConnection, group_id: int, data: GroupPermissionAssign):
|
||||||
|
await conn.execute("DELETE FROM group_permissions WHERE group_id = %s", (group_id,))
|
||||||
|
for pid in data.permission_ids:
|
||||||
|
await conn.execute(
|
||||||
|
"INSERT INTO group_permissions (group_id, permission_id) VALUES (%s, %s) ON CONFLICT DO NOTHING",
|
||||||
|
(group_id, pid)
|
||||||
|
)
|
||||||
|
await conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def group_permissions(conn: AsyncConnection, group_id: int) -> list[dict]:
|
||||||
|
rows = await (await conn.execute(
|
||||||
|
"""SELECT p.id, p.module, p.action, p.description
|
||||||
|
FROM group_permissions gp
|
||||||
|
JOIN permissions p ON p.id = gp.permission_id
|
||||||
|
WHERE gp.group_id = %s ORDER BY p.module, p.action""",
|
||||||
|
(group_id,)
|
||||||
|
)).fetchall()
|
||||||
|
return [{"id": r[0], "module": r[1], "action": r[2], "description": r[3]} for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
async def assign_user_to_group(conn: AsyncConnection, data: UserGroupAssign, granted_by: int):
|
||||||
|
await conn.execute(
|
||||||
|
"INSERT INTO user_groups (user_id, group_id, granted_by) VALUES (%s, %s, %s) ON CONFLICT DO NOTHING",
|
||||||
|
(data.user_id, data.group_id, granted_by)
|
||||||
|
)
|
||||||
|
await conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def remove_user_from_group(conn: AsyncConnection, user_id: int, group_id: int):
|
||||||
|
await conn.execute(
|
||||||
|
"DELETE FROM user_groups WHERE user_id = %s AND group_id = %s",
|
||||||
|
(user_id, group_id)
|
||||||
|
)
|
||||||
|
await conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def user_groups(conn: AsyncConnection, user_id: int) -> list[dict]:
|
||||||
|
rows = await (await conn.execute(
|
||||||
|
"""SELECT pg.id, pg.name, pg.is_superuser, ug.granted_at
|
||||||
|
FROM user_groups ug
|
||||||
|
JOIN permission_groups pg ON pg.id = ug.group_id
|
||||||
|
WHERE ug.user_id = %s ORDER BY pg.name""",
|
||||||
|
(user_id,)
|
||||||
|
)).fetchall()
|
||||||
|
return [{"id": r[0], "name": r[1], "is_superuser": r[2], "granted_at": r[3]} for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def _group_row(r) -> dict:
|
||||||
|
return {"id": r[0], "name": r[1], "description": r[2], "is_superuser": r[3], "created_at": r[4]}
|
||||||
49
mm_data/migrate.py
Normal file
49
mm_data/migrate.py
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Migration runner — migrations/ dizinindeki SQL dosyalarını sırayla çalıştırır.
|
||||||
|
Hangi migration'ların çalıştığını schema_migrations tablosunda tutar.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import psycopg
|
||||||
|
from pathlib import Path
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
load_dotenv(Path(__file__).parent.parent / ".env") # normal konum: new/.env
|
||||||
|
load_dotenv(Path(__file__).parent / ".env", override=False) # sunucu geçici konum
|
||||||
|
|
||||||
|
DSN = os.environ["DATABASE_URL"]
|
||||||
|
_base = Path(__file__).parent
|
||||||
|
MIGRATIONS_DIR = (_base.parent / "migrations") if (_base.parent / "migrations").exists() else (_base / "migrations")
|
||||||
|
|
||||||
|
|
||||||
|
def run():
|
||||||
|
with psycopg.connect(DSN) as conn:
|
||||||
|
conn.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||||
|
filename TEXT PRIMARY KEY,
|
||||||
|
applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
applied = {row[0] for row in conn.execute("SELECT filename FROM schema_migrations")}
|
||||||
|
|
||||||
|
files = sorted(f for f in MIGRATIONS_DIR.glob("*.sql") if f.name not in applied)
|
||||||
|
|
||||||
|
if not files:
|
||||||
|
print("Uygulanacak migration yok.")
|
||||||
|
return
|
||||||
|
|
||||||
|
for f in files:
|
||||||
|
print(f" → {f.name} ...", end=" ", flush=True)
|
||||||
|
sql = f.read_text(encoding="utf-8")
|
||||||
|
conn.execute(sql)
|
||||||
|
conn.execute("INSERT INTO schema_migrations (filename) VALUES (%s)", (f.name,))
|
||||||
|
conn.commit()
|
||||||
|
print("OK")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
run()
|
||||||
147
mm_data/seed_admin_units.py
Normal file
147
mm_data/seed_admin_units.py
Normal file
|
|
@ -0,0 +1,147 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Türkiye idari birim tiplerini ve büyükşehir/il belediyelerini seed eder.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import psycopg
|
||||||
|
from pathlib import Path
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
_here = Path(__file__).parent
|
||||||
|
load_dotenv(_here.parent / ".env")
|
||||||
|
load_dotenv(_here / ".env", override=False)
|
||||||
|
|
||||||
|
DSN = os.environ["DATABASE_URL"]
|
||||||
|
|
||||||
|
UNIT_TYPES = [
|
||||||
|
("Büyükşehir Belediyesi", "buyuksehir-belediyesi", "5216 sayılı Kanun kapsamındaki büyükşehir belediyeleri"),
|
||||||
|
("İl Belediyesi", "il-belediyesi", "Büyükşehir olmayan illerin merkez belediyesi"),
|
||||||
|
("İlçe Belediyesi", "ilce-belediyesi", "İlçe sınırları içindeki belediye"),
|
||||||
|
("Belde Belediyesi", "belde-belediyesi", "Nüfusu 5000 ve üzeri belde belediyeleri"),
|
||||||
|
("Valilik", "valilik", "İl mülki idare amirliği"),
|
||||||
|
("Kaymakamlık", "kaymakamlık", "İlçe mülki idare amirliği"),
|
||||||
|
("Büyükşehir İlçe Belediyesi", "buyuksehir-ilce-belediyesi", "Büyükşehir sınırları içindeki ilçe belediyeleri"),
|
||||||
|
("Köy Muhtarlığı", "koy-muhtarligi", "Köy yönetim birimi"),
|
||||||
|
("Mahalle Muhtarlığı", "mahalle-muhtarligi", "Mahalle yönetim birimi"),
|
||||||
|
("İl Özel İdaresi", "il-ozel-idaresi", "İl genelinde hizmet veren idare"),
|
||||||
|
("Karayolları Bölge Müdürlüğü", "karayollari-bolge-mudurlugu", "Devlet ve il yolları sorumluluğu"),
|
||||||
|
("DSİ Bölge Müdürlüğü", "dsi-bolge-mudurlugu", "Su işleri bölge yönetimi"),
|
||||||
|
("Orman İşletme Müdürlüğü", "orman-isletme-mudurlugu", "Orman alanları yönetimi"),
|
||||||
|
("Milli Eğitim Müdürlüğü", "milli-egitim-mudurlugu", "İl/ilçe eğitim yönetimi"),
|
||||||
|
("Sağlık Müdürlüğü", "saglik-mudurlugu", "İl sağlık hizmetleri yönetimi"),
|
||||||
|
]
|
||||||
|
|
||||||
|
# 2014'ten itibaren büyükşehir olan 30 il
|
||||||
|
BUYUKSEHIR_ILLER = [
|
||||||
|
"Adana", "Ankara", "Antalya", "Aydın", "Balıkesir", "Bursa", "Denizli",
|
||||||
|
"Diyarbakır", "Erzurum", "Eskişehir", "Gaziantep", "Hatay", "İstanbul",
|
||||||
|
"İzmir", "Kahramanmaraş", "Kayseri", "Kocaeli", "Konya", "Malatya",
|
||||||
|
"Manisa", "Mardin", "Mersin", "Muğla", "Ordu", "Sakarya", "Samsun",
|
||||||
|
"Şanlıurfa", "Tekirdağ", "Trabzon", "Van",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def run():
|
||||||
|
with psycopg.connect(DSN) as conn:
|
||||||
|
# Tipler
|
||||||
|
existing_types = {
|
||||||
|
row[0] for row in conn.execute("SELECT slug FROM administrative_unit_types")
|
||||||
|
}
|
||||||
|
inserted_types = 0
|
||||||
|
for name, slug, desc in UNIT_TYPES:
|
||||||
|
if slug not in existing_types:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO administrative_unit_types (name, slug, description) VALUES (%s, %s, %s)",
|
||||||
|
(name, slug, desc)
|
||||||
|
)
|
||||||
|
inserted_types += 1
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
print(f"Birim tipleri: {inserted_types} yeni eklendi.")
|
||||||
|
|
||||||
|
# Tip ID'lerini al
|
||||||
|
types = {
|
||||||
|
row[1]: row[0]
|
||||||
|
for row in conn.execute("SELECT id, slug FROM administrative_unit_types")
|
||||||
|
}
|
||||||
|
|
||||||
|
# Büyükşehir ve il belediyeleri
|
||||||
|
existing_units = {
|
||||||
|
row[0] for row in conn.execute("SELECT name FROM administrative_units")
|
||||||
|
}
|
||||||
|
|
||||||
|
# Tüm illeri çek
|
||||||
|
iller = {
|
||||||
|
row[0]: row[1]
|
||||||
|
for row in conn.execute(
|
||||||
|
"SELECT name, id FROM locations WHERE type = 'il' ORDER BY name"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
inserted_units = 0
|
||||||
|
for il_name, loc_id in iller.items():
|
||||||
|
is_bs = il_name in BUYUKSEHIR_ILLER
|
||||||
|
|
||||||
|
if is_bs:
|
||||||
|
unit_name = f"{il_name} Büyükşehir Belediyesi"
|
||||||
|
type_id = types["buyuksehir-belediyesi"]
|
||||||
|
tarih = "2014-03-30"
|
||||||
|
else:
|
||||||
|
unit_name = f"{il_name} İl Belediyesi"
|
||||||
|
type_id = types["il-belediyesi"]
|
||||||
|
tarih = "1984-01-01"
|
||||||
|
|
||||||
|
if unit_name not in existing_units:
|
||||||
|
unit_id = conn.execute(
|
||||||
|
"INSERT INTO administrative_units (type_id, name, established_at) "
|
||||||
|
"VALUES (%s, %s, %s) RETURNING id",
|
||||||
|
(type_id, unit_name, tarih)
|
||||||
|
).fetchone()[0]
|
||||||
|
|
||||||
|
# İle bağla
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO location_administrative_units (location_id, unit_id, valid_from) "
|
||||||
|
"VALUES (%s, %s, %s)",
|
||||||
|
(loc_id, unit_id, tarih)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Büyükşehirde tüm ilçeleri de bağla
|
||||||
|
if is_bs:
|
||||||
|
ilceler = conn.execute(
|
||||||
|
"SELECT id FROM locations WHERE parent_id = %s AND type = 'ilce'",
|
||||||
|
(loc_id,)
|
||||||
|
).fetchall()
|
||||||
|
for (ilce_id,) in ilceler:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO location_administrative_units (location_id, unit_id, valid_from) "
|
||||||
|
"VALUES (%s, %s, %s)",
|
||||||
|
(ilce_id, unit_id, tarih)
|
||||||
|
)
|
||||||
|
|
||||||
|
inserted_units += 1
|
||||||
|
|
||||||
|
# Valilik — her il için
|
||||||
|
valilik_name = f"{il_name} Valiliği"
|
||||||
|
if valilik_name not in existing_units:
|
||||||
|
v_id = conn.execute(
|
||||||
|
"INSERT INTO administrative_units (type_id, name, established_at) "
|
||||||
|
"VALUES (%s, %s, %s) RETURNING id",
|
||||||
|
(types["valilik"], valilik_name, "1923-10-29")
|
||||||
|
).fetchone()[0]
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO location_administrative_units (location_id, unit_id, valid_from) "
|
||||||
|
"VALUES (%s, %s, %s)",
|
||||||
|
(loc_id, v_id, "1923-10-29")
|
||||||
|
)
|
||||||
|
inserted_units += 1
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
print(f"İdari birimler: {inserted_units} yeni eklendi.")
|
||||||
|
|
||||||
|
total = conn.execute("SELECT COUNT(*) FROM administrative_units").fetchone()[0]
|
||||||
|
print(f"Toplam idari birim: {total}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
run()
|
||||||
101
mm_data/seed_locations.py
Normal file
101
mm_data/seed_locations.py
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Eski CI4 backup'ından il ve ilçeleri yeni locations tablosuna aktarır.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import unicodedata
|
||||||
|
import psycopg
|
||||||
|
from pathlib import Path
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
load_dotenv(Path(__file__).parent.parent / ".env")
|
||||||
|
load_dotenv(Path(__file__).parent / ".env", override=False)
|
||||||
|
|
||||||
|
DSN = os.environ["DATABASE_URL"]
|
||||||
|
_here = Path(__file__).parent
|
||||||
|
BACKUP = next(
|
||||||
|
p for p in [
|
||||||
|
_here.parent.parent / "locations_backup.sql", # normal: new/../locations_backup.sql
|
||||||
|
_here.parent / "locations_backup.sql", # sunucu geçici
|
||||||
|
_here / "locations_backup.sql", # aynı dizin
|
||||||
|
]
|
||||||
|
if p.exists()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def slugify(text: str) -> str:
|
||||||
|
text = text.lower()
|
||||||
|
text = text.replace("ı", "i").replace("ğ", "g").replace("ü", "u")
|
||||||
|
text = text.replace("ş", "s").replace("ö", "o").replace("ç", "c")
|
||||||
|
text = unicodedata.normalize("NFD", text)
|
||||||
|
text = "".join(c for c in text if unicodedata.category(c) != "Mn")
|
||||||
|
text = re.sub(r"[^a-z0-9]+", "-", text).strip("-")
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def parse_inserts(sql: str, table: str):
|
||||||
|
pattern = rf"INSERT INTO public\.{table} VALUES \((.+?)\);"
|
||||||
|
rows = []
|
||||||
|
for m in re.finditer(pattern, sql):
|
||||||
|
parts = [p.strip().strip("'") for p in m.group(1).split(", ", 2)]
|
||||||
|
rows.append(parts)
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def run():
|
||||||
|
sql = BACKUP.read_text(encoding="utf-8")
|
||||||
|
provinces = parse_inserts(sql, "locations_province") # (id, name, plate_no)
|
||||||
|
districts = parse_inserts(sql, "locations_district") # (id, name, province_id)
|
||||||
|
|
||||||
|
with psycopg.connect(DSN) as conn:
|
||||||
|
existing = conn.execute("SELECT COUNT(*) FROM locations").fetchone()[0]
|
||||||
|
if existing > 0:
|
||||||
|
print(f"locations tablosunda zaten {existing} kayıt var, atlanıyor.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Türkiye kök kaydı
|
||||||
|
tr_id = conn.execute(
|
||||||
|
"INSERT INTO locations (name, slug, type) VALUES (%s, %s, %s) RETURNING id",
|
||||||
|
("Türkiye", "turkiye", "ulke")
|
||||||
|
).fetchone()[0]
|
||||||
|
|
||||||
|
# İller — eski id → yeni id eşlemesi
|
||||||
|
province_id_map = {}
|
||||||
|
for old_id, name, _ in provinces:
|
||||||
|
slug = slugify(name)
|
||||||
|
new_id = conn.execute(
|
||||||
|
"INSERT INTO locations (parent_id, name, slug, type) VALUES (%s, %s, %s, %s) RETURNING id",
|
||||||
|
(tr_id, name, slug, "il")
|
||||||
|
).fetchone()[0]
|
||||||
|
province_id_map[old_id] = new_id
|
||||||
|
|
||||||
|
# İlçeler — slug = il_slug-ilce_slug (teklik garantisi)
|
||||||
|
province_slug_map = {old_id: slugify(name) for old_id, name, _ in provinces}
|
||||||
|
used_slugs = set()
|
||||||
|
for _, name, province_old_id in districts:
|
||||||
|
parent_id = province_id_map.get(province_old_id)
|
||||||
|
if not parent_id:
|
||||||
|
print(f" ⚠ İlçe '{name}' için province_id={province_old_id} bulunamadı, atlandı.")
|
||||||
|
continue
|
||||||
|
il_slug = province_slug_map.get(province_old_id, province_old_id)
|
||||||
|
base_slug = f"{il_slug}-{slugify(name)}"
|
||||||
|
slug = base_slug
|
||||||
|
counter = 1
|
||||||
|
while slug in used_slugs:
|
||||||
|
counter += 1
|
||||||
|
slug = f"{base_slug}-{counter}"
|
||||||
|
used_slugs.add(slug)
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO locations (parent_id, name, slug, type) VALUES (%s, %s, %s, %s)",
|
||||||
|
(parent_id, name, slug, "ilce")
|
||||||
|
)
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
total = conn.execute("SELECT COUNT(*) FROM locations").fetchone()[0]
|
||||||
|
print(f"Tamamlandı: {total} lokasyon eklendi (1 ülke, {len(provinces)} il, {len(districts)} ilçe).")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
run()
|
||||||
Loading…
Add table
Reference in a new issue