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:
Mukan Erkin TÖRÜK 2026-04-27 23:06:59 +03:00
commit 2498e75594
45 changed files with 3434 additions and 0 deletions

9
.gitignore vendored Normal file
View file

@ -0,0 +1,9 @@
.venv/
__pycache__/
*.pyc
*.pyo
.env
*.db
*.sqlite
/uploads/
.DS_Store

View 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);

View 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;

View 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);

View 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);

View 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);

View 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);

View 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;

View 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;

View 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;

View 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);

View 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;

View 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;

View 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
View file

15
mm_api/db.py Normal file
View 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
View 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
View 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
View 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)

View file

View 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
View 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
View 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

View 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

View file

View 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
View 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
View 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
View 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
View 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}

View 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
View 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))

View 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)

View file

View 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
View 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
View 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
View 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
View 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
View file

@ -0,0 +1,124 @@
"""
Lokasyon 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
View 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,
}

View 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 ı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
View 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
View 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
View 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()