commit 2498e75594659bcc2ac140ade74dca24ffb4e8d381b42530c719622cadec0a4c Author: Mukan Erkin Date: Mon Apr 27 23:06:59 2026 +0300 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. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..297c19d --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +.venv/ +__pycache__/ +*.pyc +*.pyo +.env +*.db +*.sqlite +/uploads/ +.DS_Store diff --git a/migrations/0001_locations.sql b/migrations/0001_locations.sql new file mode 100644 index 0000000..7a9a7e2 --- /dev/null +++ b/migrations/0001_locations.sql @@ -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); diff --git a/migrations/0002_users_and_permissions.sql b/migrations/0002_users_and_permissions.sql new file mode 100644 index 0000000..418cfce --- /dev/null +++ b/migrations/0002_users_and_permissions.sql @@ -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; diff --git a/migrations/0003_issues.sql b/migrations/0003_issues.sql new file mode 100644 index 0000000..6c2a657 --- /dev/null +++ b/migrations/0003_issues.sql @@ -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); diff --git a/migrations/0004_officials_and_reports.sql b/migrations/0004_officials_and_reports.sql new file mode 100644 index 0000000..6c1383c --- /dev/null +++ b/migrations/0004_officials_and_reports.sql @@ -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); diff --git a/migrations/0005_location_changes.sql b/migrations/0005_location_changes.sql new file mode 100644 index 0000000..b5ac129 --- /dev/null +++ b/migrations/0005_location_changes.sql @@ -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); diff --git a/migrations/0006_auth_tokens.sql b/migrations/0006_auth_tokens.sql new file mode 100644 index 0000000..a18a709 --- /dev/null +++ b/migrations/0006_auth_tokens.sql @@ -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); diff --git a/migrations/0007_permissions_refactor.sql b/migrations/0007_permissions_refactor.sql new file mode 100644 index 0000000..4d9e4b4 --- /dev/null +++ b/migrations/0007_permissions_refactor.sql @@ -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; diff --git a/migrations/0008_permissions_scope.sql b/migrations/0008_permissions_scope.sql new file mode 100644 index 0000000..15321df --- /dev/null +++ b/migrations/0008_permissions_scope.sql @@ -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; diff --git a/migrations/0009_session_cleanup.sql b/migrations/0009_session_cleanup.sql new file mode 100644 index 0000000..a060019 --- /dev/null +++ b/migrations/0009_session_cleanup.sql @@ -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; diff --git a/migrations/0010_kyc_upgrade.sql b/migrations/0010_kyc_upgrade.sql new file mode 100644 index 0000000..c27d34a --- /dev/null +++ b/migrations/0010_kyc_upgrade.sql @@ -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); diff --git a/migrations/0011_issue_categories_seed.sql b/migrations/0011_issue_categories_seed.sql new file mode 100644 index 0000000..64c80af --- /dev/null +++ b/migrations/0011_issue_categories_seed.sql @@ -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; diff --git a/migrations/0012_issue_media.sql b/migrations/0012_issue_media.sql new file mode 100644 index 0000000..48835c3 --- /dev/null +++ b/migrations/0012_issue_media.sql @@ -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; diff --git a/migrations/0013_api_clients.sql b/migrations/0013_api_clients.sql new file mode 100644 index 0000000..f22195f --- /dev/null +++ b/migrations/0013_api_clients.sql @@ -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() +); diff --git a/mm_api/__init__.py b/mm_api/__init__.py new file mode 100644 index 0000000..473a0f4 diff --git a/mm_api/db.py b/mm_api/db.py new file mode 100644 index 0000000..9cfe696 --- /dev/null +++ b/mm_api/db.py @@ -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 diff --git a/mm_api/dependencies.py b/mm_api/dependencies.py new file mode 100644 index 0000000..a19752e --- /dev/null +++ b/mm_api/dependencies.py @@ -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 diff --git a/mm_api/main.py b/mm_api/main.py new file mode 100644 index 0000000..dd66f2f --- /dev/null +++ b/mm_api/main.py @@ -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) diff --git a/mm_api/middleware.py b/mm_api/middleware.py new file mode 100644 index 0000000..e7ffde1 --- /dev/null +++ b/mm_api/middleware.py @@ -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) diff --git a/mm_api/models/__init__.py b/mm_api/models/__init__.py new file mode 100644 index 0000000..473a0f4 diff --git a/mm_api/models/admin_unit.py b/mm_api/models/admin_unit.py new file mode 100644 index 0000000..c30ab58 --- /dev/null +++ b/mm_api/models/admin_unit.py @@ -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 diff --git a/mm_api/models/auth.py b/mm_api/models/auth.py new file mode 100644 index 0000000..7db5fcd --- /dev/null +++ b/mm_api/models/auth.py @@ -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 diff --git a/mm_api/models/location.py b/mm_api/models/location.py new file mode 100644 index 0000000..229d34d --- /dev/null +++ b/mm_api/models/location.py @@ -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 diff --git a/mm_api/models/permission.py b/mm_api/models/permission.py new file mode 100644 index 0000000..5e0a006 --- /dev/null +++ b/mm_api/models/permission.py @@ -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 diff --git a/mm_api/routers/__init__.py b/mm_api/routers/__init__.py new file mode 100644 index 0000000..473a0f4 diff --git a/mm_api/routers/admin_units.py b/mm_api/routers/admin_units.py new file mode 100644 index 0000000..eef5e2c --- /dev/null +++ b/mm_api/routers/admin_units.py @@ -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) diff --git a/mm_api/routers/auth.py b/mm_api/routers/auth.py new file mode 100644 index 0000000..6c2b34d --- /dev/null +++ b/mm_api/routers/auth.py @@ -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} diff --git a/mm_api/routers/clients.py b/mm_api/routers/clients.py new file mode 100644 index 0000000..b8adb6f --- /dev/null +++ b/mm_api/routers/clients.py @@ -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)) diff --git a/mm_api/routers/issues.py b/mm_api/routers/issues.py new file mode 100644 index 0000000..4c76c02 --- /dev/null +++ b/mm_api/routers/issues.py @@ -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)) diff --git a/mm_api/routers/kyc.py b/mm_api/routers/kyc.py new file mode 100644 index 0000000..30418fc --- /dev/null +++ b/mm_api/routers/kyc.py @@ -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} diff --git a/mm_api/routers/locations.py b/mm_api/routers/locations.py new file mode 100644 index 0000000..8a7dd6f --- /dev/null +++ b/mm_api/routers/locations.py @@ -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) diff --git a/mm_api/routers/officials.py b/mm_api/routers/officials.py new file mode 100644 index 0000000..89c2983 --- /dev/null +++ b/mm_api/routers/officials.py @@ -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)) diff --git a/mm_api/routers/permissions.py b/mm_api/routers/permissions.py new file mode 100644 index 0000000..67caabe --- /dev/null +++ b/mm_api/routers/permissions.py @@ -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) diff --git a/mm_api/services/__init__.py b/mm_api/services/__init__.py new file mode 100644 index 0000000..473a0f4 diff --git a/mm_api/services/admin_unit.py b/mm_api/services/admin_unit.py new file mode 100644 index 0000000..694d211 --- /dev/null +++ b/mm_api/services/admin_unit.py @@ -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], + } diff --git a/mm_api/services/auth.py b/mm_api/services/auth.py new file mode 100644 index 0000000..815de9a --- /dev/null +++ b/mm_api/services/auth.py @@ -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()), + } diff --git a/mm_api/services/client.py b/mm_api/services/client.py new file mode 100644 index 0000000..4afc957 --- /dev/null +++ b/mm_api/services/client.py @@ -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]} diff --git a/mm_api/services/issue.py b/mm_api/services/issue.py new file mode 100644 index 0000000..f63754b --- /dev/null +++ b/mm_api/services/issue.py @@ -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} diff --git a/mm_api/services/kyc.py b/mm_api/services/kyc.py new file mode 100644 index 0000000..52bdff8 --- /dev/null +++ b/mm_api/services/kyc.py @@ -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 diff --git a/mm_api/services/location.py b/mm_api/services/location.py new file mode 100644 index 0000000..1bf3973 --- /dev/null +++ b/mm_api/services/location.py @@ -0,0 +1,124 @@ +""" +Lokasyon iş mantığı — her operasyon history snapshot'ı atar ve +ilgili değişiklik tablolarını otomatik günceller. +""" + +import json +from datetime import datetime +from psycopg import AsyncConnection +from mm_api.models.location import LocationCreate, LocationRetype, LocationReparent, LocationSplitFrom, LocationMerge + + +async def _snapshot(conn: AsyncConnection, location_id: int, change_reason: str, changed_by: int | None): + row = await (await conn.execute( + "SELECT id, parent_id, name, slug, type, latitude, longitude, is_active, created_at, updated_at " + "FROM locations WHERE id = %s", + (location_id,) + )).fetchone() + if not row: + return + snap = { + "id": row[0], "parent_id": row[1], "name": row[2], "slug": row[3], + "type": row[4], "latitude": row[5], "longitude": row[6], + "is_active": row[7], + } + await conn.execute( + "INSERT INTO location_history (location_id, snapshot, change_reason, changed_by) VALUES (%s, %s, %s, %s)", + (location_id, json.dumps(snap), change_reason, changed_by) + ) + + +async def create(conn: AsyncConnection, data: LocationCreate) -> dict: + row = await (await conn.execute( + "INSERT INTO locations (parent_id, name, slug, type, latitude, longitude) " + "VALUES (%s, %s, %s, %s, %s, %s) RETURNING *", + (data.parent_id, data.name, data.slug, data.type.value, data.latitude, data.longitude) + )).fetchone() + await conn.commit() + return _row_to_dict(row) + + +async def get(conn: AsyncConnection, location_id: int) -> dict | None: + row = await (await conn.execute( + "SELECT * FROM locations WHERE id = %s", (location_id,) + )).fetchone() + return _row_to_dict(row) if row else None + + +async def list_children(conn: AsyncConnection, parent_id: int | None) -> list[dict]: + if parent_id is None: + rows = await (await conn.execute( + "SELECT * FROM locations WHERE parent_id IS NULL ORDER BY name" + )).fetchall() + else: + rows = await (await conn.execute( + "SELECT * FROM locations WHERE parent_id = %s ORDER BY name", (parent_id,) + )).fetchall() + return [_row_to_dict(r) for r in rows] + + +async def retype(conn: AsyncConnection, location_id: int, data: LocationRetype): + await _snapshot(conn, location_id, data.change_reason, data.changed_by) + await conn.execute( + "UPDATE locations SET type = %s, updated_at = NOW() WHERE id = %s", + (data.new_type.value, location_id) + ) + await conn.commit() + + +async def reparent(conn: AsyncConnection, location_id: int, data: LocationReparent): + await _snapshot(conn, location_id, data.change_reason, data.changed_by) + await conn.execute( + "UPDATE locations SET parent_id = %s, updated_at = NOW() WHERE id = %s", + (data.new_parent_id, location_id) + ) + await conn.commit() + + +async def add_split_from(conn: AsyncConnection, location_id: int, data: LocationSplitFrom): + await conn.execute( + "INSERT INTO location_splits (location_id, source_id, effective_at, notes) " + "VALUES (%s, %s, %s, %s) ON CONFLICT DO NOTHING", + (location_id, data.source_id, data.effective_at, data.notes) + ) + await conn.commit() + + +async def merge(conn: AsyncConnection, data: LocationMerge): + """ + source_ids içindeki lokasyonları pasifleştirir, target_id'ye birleşme kaydı ekler, + her kaynak için location_history snapshot'ı atar. + """ + for src_id in data.source_ids: + await _snapshot(conn, src_id, data.change_reason, data.changed_by) + await conn.execute( + "UPDATE locations SET is_active = FALSE, updated_at = NOW() WHERE id = %s", + (src_id,) + ) + await conn.execute( + "INSERT INTO location_merges (source_id, target_id, effective_at, notes) " + "VALUES (%s, %s, %s, %s) ON CONFLICT DO NOTHING", + (src_id, data.target_id, data.effective_at, data.notes) + ) + await conn.commit() + + +async def history(conn: AsyncConnection, location_id: int) -> list[dict]: + rows = await (await conn.execute( + "SELECT id, location_id, snapshot, change_reason, changed_by, changed_at " + "FROM location_history WHERE location_id = %s ORDER BY changed_at", + (location_id,) + )).fetchall() + return [ + {"id": r[0], "location_id": r[1], "snapshot": r[2], + "change_reason": r[3], "changed_by": r[4], "changed_at": r[5]} + for r in rows + ] + + +def _row_to_dict(row) -> dict: + return { + "id": row[0], "parent_id": row[1], "name": row[2], "slug": row[3], + "type": row[4], "latitude": row[5], "longitude": row[6], + "is_active": row[7], "created_at": row[8], "updated_at": row[9], + } diff --git a/mm_api/services/official.py b/mm_api/services/official.py new file mode 100644 index 0000000..f158f32 --- /dev/null +++ b/mm_api/services/official.py @@ -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, + } diff --git a/mm_api/services/permission.py b/mm_api/services/permission.py new file mode 100644 index 0000000..27fa6a2 --- /dev/null +++ b/mm_api/services/permission.py @@ -0,0 +1,135 @@ +from psycopg import AsyncConnection +from mm_api.models.permission import PermissionGroupCreate, GroupPermissionAssign, UserGroupAssign + + +async def can(conn: AsyncConnection, user_id: int, action: str, module: str, is_admin: bool = False) -> bool: + """ + Kullanıcının belirtilen modül+eylem için yetkisi var mı? + is_admin=True → admin kapsamı (herhangi bir içerik); False → yalnızca kendi içeriği. + Süper kullanıcı grubundaysa is_admin değerinden bağımsız direkt True. + scope=FALSE olan izinler her iki durumda da geçerlidir (kendi içeriği hem admin hem normal kullanıcıya açık). + """ + row = await (await conn.execute( + """ + SELECT EXISTS ( + SELECT 1 + FROM user_groups ug + JOIN permission_groups pg ON pg.id = ug.group_id + LEFT JOIN group_permissions gp ON gp.group_id = pg.id + LEFT JOIN permissions p ON p.id = gp.permission_id + WHERE ug.user_id = %s + AND ( + pg.is_superuser = TRUE + OR ( + p.module IN (%s, '*') + AND p.action IN (%s, '*') + AND (p.scope = %s OR p.scope = FALSE) + ) + ) + ) + """, + (user_id, module, action, is_admin) + )).fetchone() + return row[0] if row else False + + +async def user_permissions(conn: AsyncConnection, user_id: int) -> list[dict]: + """Kullanıcının sahip olduğu tüm izinleri döner.""" + rows = await (await conn.execute( + """ + SELECT DISTINCT p.module, p.action, p.description, pg.is_superuser + FROM user_groups ug + JOIN permission_groups pg ON pg.id = ug.group_id + LEFT JOIN group_permissions gp ON gp.group_id = pg.id + LEFT JOIN permissions p ON p.id = gp.permission_id + WHERE ug.user_id = %s + ORDER BY p.module, p.action + """, + (user_id,) + )).fetchall() + if any(r[3] for r in rows): # süper kullanıcı grubundaysa + return [{"module": "*", "action": "*", "description": "Tüm izinler", "is_superuser": True}] + return [{"module": r[0], "action": r[1], "description": r[2], "is_superuser": False} for r in rows if r[0]] + + +async def list_permissions(conn: AsyncConnection) -> list[dict]: + rows = await (await conn.execute( + "SELECT id, module, action, description FROM permissions ORDER BY module, action" + )).fetchall() + return [{"id": r[0], "module": r[1], "action": r[2], "description": r[3]} for r in rows] + + +async def list_groups(conn: AsyncConnection) -> list[dict]: + rows = await (await conn.execute( + "SELECT id, name, description, is_superuser, created_at FROM permission_groups ORDER BY name" + )).fetchall() + return [_group_row(r) for r in rows] + + +async def get_group(conn: AsyncConnection, group_id: int) -> dict | None: + row = await (await conn.execute( + "SELECT id, name, description, is_superuser, created_at FROM permission_groups WHERE id = %s", + (group_id,) + )).fetchone() + return _group_row(row) if row else None + + +async def create_group(conn: AsyncConnection, data: PermissionGroupCreate) -> dict: + row = await (await conn.execute( + "INSERT INTO permission_groups (name, description, is_superuser) VALUES (%s, %s, %s) RETURNING id", + (data.name, data.description, data.is_superuser) + )).fetchone() + await conn.commit() + return await get_group(conn, row[0]) + + +async def set_group_permissions(conn: AsyncConnection, group_id: int, data: GroupPermissionAssign): + await conn.execute("DELETE FROM group_permissions WHERE group_id = %s", (group_id,)) + for pid in data.permission_ids: + await conn.execute( + "INSERT INTO group_permissions (group_id, permission_id) VALUES (%s, %s) ON CONFLICT DO NOTHING", + (group_id, pid) + ) + await conn.commit() + + +async def group_permissions(conn: AsyncConnection, group_id: int) -> list[dict]: + rows = await (await conn.execute( + """SELECT p.id, p.module, p.action, p.description + FROM group_permissions gp + JOIN permissions p ON p.id = gp.permission_id + WHERE gp.group_id = %s ORDER BY p.module, p.action""", + (group_id,) + )).fetchall() + return [{"id": r[0], "module": r[1], "action": r[2], "description": r[3]} for r in rows] + + +async def assign_user_to_group(conn: AsyncConnection, data: UserGroupAssign, granted_by: int): + await conn.execute( + "INSERT INTO user_groups (user_id, group_id, granted_by) VALUES (%s, %s, %s) ON CONFLICT DO NOTHING", + (data.user_id, data.group_id, granted_by) + ) + await conn.commit() + + +async def remove_user_from_group(conn: AsyncConnection, user_id: int, group_id: int): + await conn.execute( + "DELETE FROM user_groups WHERE user_id = %s AND group_id = %s", + (user_id, group_id) + ) + await conn.commit() + + +async def user_groups(conn: AsyncConnection, user_id: int) -> list[dict]: + rows = await (await conn.execute( + """SELECT pg.id, pg.name, pg.is_superuser, ug.granted_at + FROM user_groups ug + JOIN permission_groups pg ON pg.id = ug.group_id + WHERE ug.user_id = %s ORDER BY pg.name""", + (user_id,) + )).fetchall() + return [{"id": r[0], "name": r[1], "is_superuser": r[2], "granted_at": r[3]} for r in rows] + + +def _group_row(r) -> dict: + return {"id": r[0], "name": r[1], "description": r[2], "is_superuser": r[3], "created_at": r[4]} diff --git a/mm_data/migrate.py b/mm_data/migrate.py new file mode 100644 index 0000000..01786e8 --- /dev/null +++ b/mm_data/migrate.py @@ -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() diff --git a/mm_data/seed_admin_units.py b/mm_data/seed_admin_units.py new file mode 100644 index 0000000..4a19711 --- /dev/null +++ b/mm_data/seed_admin_units.py @@ -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() diff --git a/mm_data/seed_locations.py b/mm_data/seed_locations.py new file mode 100644 index 0000000..f69d415 --- /dev/null +++ b/mm_data/seed_locations.py @@ -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()