Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions backend/src/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -379,6 +379,69 @@ async def get_own_profile(current_user: Annotated[User, Depends(get_current_user
return SuccessResponse(data=current_user)


@app.get("/api/v1/users/me/data-export", tags=["User"], summary="Export the current user's stored data")
async def export_own_data(current_user: Annotated[User, Depends(get_current_user)]):
questionnaires = await Questionnaire.find({"user_id": current_user.id}).sort("created_at").to_list()
risk_assessments = await HealthRiskAssessment.find({"user_id": current_user.id}).sort("created_at").to_list()

export = {
"schema_version": "1.0",
"exported_at": datetime.now(timezone.utc),
"profile": {
"username": current_user.username,
"name": current_user.name,
"date_of_birth": current_user.date_of_birth,
"gender": current_user.gender,
"phone": current_user.phone,
"photo_url": current_user.photo_url,
"created_at": current_user.created_at,
"updated_at": current_user.updated_at,
},
Comment thread
mudabs marked this conversation as resolved.
Resolved
"consent": {
"consent_given": current_user.consent_given,
"data_usage": current_user.data_usage,
"marketing": current_user.marketing,
"version": current_user.consent_version,
"recorded_at": current_user.consent_timestamp,
},
"questionnaires": [
{
"submitted_at": record.created_at,
"first_name": record.first_name,
"last_name": record.last_name,
"date_of_birth": record.date_of_birth,
"gender": record.gender,
"email": record.email,
"phone": record.phone,
"existing_conditions": record.existing_conditions,
"allergies": record.allergies,
"current_medications": record.current_medications,
"family_history": record.family_history,
"exercise_frequency": record.exercise_frequency,
"diet_type": record.diet_type,
"smoking_status": record.smoking_status,
"alcohol_consumption": record.alcohol_consumption,
"sleep_hours": record.sleep_hours,
"stress_level": record.stress_level,
}
for record in questionnaires
],
"risk_assessments": [
{
"assessed_at": record.created_at,
"result": record.result.model_dump(mode="json"),
}
for record in risk_assessments
],
}
return JSONResponse(
content=jsonable_encoder(export),
headers={
"Cache-Control": "no-store",
},
)


@app.post("/api/v1/consent", response_model=SuccessResponse[UserResponse], tags=["Consent"])
@limiter.limit("10/minute")
async def submit_consent(
Expand Down
95 changes: 94 additions & 1 deletion backend/tests/test_health_summary.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import unittest
import json
from datetime import date, datetime, timezone
from types import SimpleNamespace
from unittest.mock import patch
Expand All @@ -8,7 +9,7 @@
from starlette.requests import Request

from src.health_summary import build_health_summary
from src.main import app, calculate_risk_assessment, get_health_summary
from src.main import app, calculate_risk_assessment, export_own_data, get_health_summary
from src.models import HealthRiskAssessment, HealthRiskInput, HealthRiskOutput, GenderEnum, Questionnaire


Expand Down Expand Up @@ -178,5 +179,97 @@ async def insert(self):
self.assertEqual(response.data, result)


class UserDataExportTests(unittest.IsolatedAsyncioTestCase):
def setUp(self):
self.user = SimpleNamespace(
id=uuid4(),
username="export-user",
name="Export User",
date_of_birth=date(1990, 1, 2),
gender=GenderEnum.female,
phone="+15555550123",
password_hash="must-not-export",
consent_given=True,
data_usage=True,
marketing=False,
consent_version="v1.0",
consent_timestamp=datetime(2026, 1, 1, tzinfo=timezone.utc),
photo_url=None,
created_at=datetime(2025, 1, 1, tzinfo=timezone.utc),
updated_at=datetime(2026, 1, 1, tzinfo=timezone.utc),
)

async def test_export_scopes_records_to_current_user_and_excludes_credentials(self):
queries = {}
questionnaire = SimpleNamespace(
created_at=datetime(2026, 2, 1, tzinfo=timezone.utc),
first_name="Export",
last_name="User",
date_of_birth="1990-01-02",
gender=GenderEnum.female,
email="export@example.test",
phone=None,
existing_conditions=["asthma"],
allergies="None",
current_medications="None",
family_history="None",
exercise_frequency="weekly",
diet_type="mixed",
smoking_status="never",
alcohol_consumption="none",
sleep_hours="8",
stress_level="low",
)
risk_result = HealthRiskOutput(
risk_score=4.2,
risk_category="low",
risk_category_description="Low estimated risk",
factor_breakdown={
"age_factor": 1,
"cholesterol_factor": 1,
"blood_pressure_factor": 1,
"smoking_factor": 1,
"diabetes_factor": 0,
},
recommendations=["Discuss results with a healthcare professional"],
)
assessment = SimpleNamespace(created_at=datetime(2026, 2, 2, tzinfo=timezone.utc), result=risk_result)

def find_for(collection, records):
def find(query):
queries[collection] = query
return FakeQuery(records)
return find

with (
patch.object(Questionnaire, "find", side_effect=find_for("questionnaire", [questionnaire])),
patch.object(HealthRiskAssessment, "find", side_effect=find_for("risk", [assessment])),
):
response = await export_own_data(self.user)

data = json.loads(response.body)
self.assertEqual(queries["questionnaire"], {"user_id": self.user.id})
self.assertEqual(queries["risk"], {"user_id": self.user.id})
self.assertEqual(data["questionnaires"][0]["existing_conditions"], ["asthma"])
self.assertEqual(data["risk_assessments"][0]["result"]["risk_score"], 4.2)
self.assertNotIn("password_hash", response.body.decode())
self.assertEqual(response.headers["cache-control"], "no-store")

async def test_export_succeeds_with_empty_health_history(self):
self.user.consent_given = False
self.user.data_usage = False
with (
patch.object(Questionnaire, "find", return_value=FakeQuery([])),
patch.object(HealthRiskAssessment, "find", return_value=FakeQuery([])),
):
response = await export_own_data(self.user)

data = json.loads(response.body)
self.assertFalse(data["consent"]["consent_given"])
self.assertEqual(data["questionnaires"], [])
self.assertEqual(data["risk_assessments"], [])
self.assertEqual(data["profile"]["username"], self.user.username)


if __name__ == "__main__":
unittest.main()
14 changes: 14 additions & 0 deletions frontend/public/locales/ar/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,20 @@
"home": "الصفحة الرئيسية",
"profileInformation": "معلومات الملف الشخصي",
"accountSettings": "إعدادات الحساب",
"privacyControlsTitle": "الخصوصية وبياناتك",
"privacyExportTitle": "تصدير بياناتك",
"privacyExportDescription": "نزّل نسخة JSON من ملفك الشخصي وسجل الموافقة والاستبيانات المقدمة وتقييمات المخاطر المحفوظة. يقتصر التصدير على حسابك ولا يحتفظ به التطبيق بشكل منفصل.",
"privacyExportButton": "تنزيل بياناتي",
"privacyExportLoading": "جارٍ تجهيز التصدير...",
"privacyExportSuccess": "تم تنزيل تصدير بياناتك.",
"privacyExportEmpty": "تم تنزيل التصدير. لا توجد لديك استبيانات أو تقييمات مخاطر محفوظة حتى الآن.",
"privacyExportError": "تعذر تصدير بياناتك. تحقق من اتصالك وحاول مرة أخرى.",
"privacyRetentionTitle": "الاحتفاظ بالبيانات وحذفها",
"privacyRetentionDescription": "لا ينشر التطبيق حاليًا مدة احتفاظ تلقائية ولا يوفر الحذف الذاتي. تواصل مع مسؤول التطبيق للاستفسار عن حذف بيانات حسابك. تبقى ملفات التصدير التي تنزّلها على جهازك ولا يحذفها التطبيق.",
"privacyConsentTitle": "الموافقة",
"privacyConsentGranted": "تم تسجيل موافقتك على بيانات الصحة في حسابك حاليًا.",
"privacyConsentNotGranted": "لم يتم تسجيل موافقتك على بيانات الصحة في حسابك حاليًا.",
"privacyConsentDescription": "تتطلب ميزات الصحة موافقة سارية. لا يمكن سحب الموافقة من الإعدادات حاليًا؛ تواصل مع مسؤول التطبيق للاستفسار. موافقة التسويق الاختيارية منفصلة عن موافقة ميزات الصحة.",
"notFoundTitle": "404 - غير موجود",
"goHome": "الانتقال إلى الصفحة الرئيسية",
"chooseLanguage": "اختر لغتك",
Expand Down
14 changes: 14 additions & 0 deletions frontend/public/locales/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,20 @@
"optional": "Optional",
"home": "Home",
"accountSettings": "Account Settings",
"privacyControlsTitle": "Privacy and your data",
"privacyExportTitle": "Export your data",
"privacyExportDescription": "Download a JSON copy of your profile, consent record, submitted questionnaires, and saved risk assessments. The export is limited to your account and is not stored separately by the app.",
"privacyExportButton": "Download my data",
"privacyExportLoading": "Preparing export...",
"privacyExportSuccess": "Your data export was downloaded.",
"privacyExportEmpty": "Your export was downloaded. You have no saved questionnaire or risk assessment records yet.",
"privacyExportError": "We could not export your data. Check your connection and try again.",
"privacyRetentionTitle": "Retention and deletion",
"privacyRetentionDescription": "The app does not currently publish an automatic retention period or provide self-service deletion. Contact your app administrator to ask about deleting your account data. Downloaded export files are stored on your device and are not deleted by the app.",
"privacyConsentTitle": "Consent",
"privacyConsentGranted": "Health data consent is currently recorded for your account.",
"privacyConsentNotGranted": "Health data consent is not currently recorded for your account.",
"privacyConsentDescription": "Health features require current consent. Consent cannot currently be withdrawn from settings; contact your app administrator with questions. Optional marketing consent is separate from health-feature consent.",
"notFoundTitle": "404 - Not Found",
"goHome": "Go Home",
"chooseLanguage": "Choose your language",
Expand Down
14 changes: 14 additions & 0 deletions frontend/public/locales/es/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,20 @@
"optional": "Opcional",
"home": "Inicio",
"accountSettings": "Configuración de la cuenta",
"privacyControlsTitle": "Privacidad y tus datos",
"privacyExportTitle": "Exportar tus datos",
"privacyExportDescription": "Descarga una copia JSON de tu perfil, registro de consentimiento, cuestionarios enviados y evaluaciones de riesgo guardadas. La exportación se limita a tu cuenta y la aplicación no la almacena por separado.",
"privacyExportButton": "Descargar mis datos",
"privacyExportLoading": "Preparando la exportación...",
"privacyExportSuccess": "Se descargó la exportación de tus datos.",
"privacyExportEmpty": "Se descargó la exportación. Aún no tienes cuestionarios ni evaluaciones de riesgo guardados.",
"privacyExportError": "No se pudieron exportar tus datos. Comprueba tu conexión e inténtalo de nuevo.",
"privacyRetentionTitle": "Retención y eliminación",
"privacyRetentionDescription": "La aplicación no publica actualmente un plazo de retención automático ni ofrece eliminación autoservicio. Contacta con el administrador para solicitar la eliminación de los datos de tu cuenta. Los archivos descargados permanecen en tu dispositivo y la aplicación no los elimina.",
"privacyConsentTitle": "Consentimiento",
"privacyConsentGranted": "Tu cuenta tiene registrado actualmente el consentimiento para datos de salud.",
"privacyConsentNotGranted": "Tu cuenta no tiene registrado actualmente el consentimiento para datos de salud.",
"privacyConsentDescription": "Las funciones de salud requieren consentimiento vigente. Actualmente no se puede retirar desde la configuración; contacta con el administrador si tienes preguntas. El consentimiento opcional para comunicaciones de marketing es independiente.",
"notFoundTitle": "404 - No encontrado",
"goHome": "Ir al inicio",
"chooseLanguage": "Elige tu idioma",
Expand Down
14 changes: 14 additions & 0 deletions frontend/public/locales/hi/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,20 @@
"optional": "वैकल्पिक",
"home": "होम",
"accountSettings": "खाता सेटिंग",
"privacyControlsTitle": "गोपनीयता और आपका डेटा",
"privacyExportTitle": "अपना डेटा निर्यात करें",
"privacyExportDescription": "अपनी प्रोफ़ाइल, सहमति रिकॉर्ड, सबमिट की गई प्रश्नावलियों और सहेजे गए जोखिम आकलनों की JSON प्रति डाउनलोड करें। निर्यात केवल आपके खाते का है और ऐप इसे अलग से संग्रहीत नहीं करता।",
"privacyExportButton": "मेरा डेटा डाउनलोड करें",
"privacyExportLoading": "निर्यात तैयार हो रहा है...",
"privacyExportSuccess": "आपका डेटा निर्यात डाउनलोड हो गया।",
"privacyExportEmpty": "आपका निर्यात डाउनलोड हो गया। अभी कोई प्रश्नावली या जोखिम आकलन रिकॉर्ड सहेजा नहीं गया है।",
"privacyExportError": "आपका डेटा निर्यात नहीं हो सका। कनेक्शन जाँचें और फिर कोशिश करें।",
"privacyRetentionTitle": "डेटा रखना और हटाना",
"privacyRetentionDescription": "ऐप अभी स्वचालित डेटा-रखने की अवधि प्रकाशित नहीं करता और स्वयं डेटा हटाने की सुविधा नहीं देता। खाता डेटा हटाने के बारे में पूछने के लिए ऐप व्यवस्थापक से संपर्क करें। डाउनलोड की गई फ़ाइलें आपके डिवाइस पर रहती हैं और ऐप उन्हें नहीं हटाता।",
"privacyConsentTitle": "सहमति",
"privacyConsentGranted": "आपके खाते में स्वास्थ्य डेटा की सहमति दर्ज है।",
"privacyConsentNotGranted": "आपके खाते में स्वास्थ्य डेटा की सहमति दर्ज नहीं है।",
"privacyConsentDescription": "स्वास्थ्य सुविधाओं के लिए वर्तमान सहमति आवश्यक है। सेटिंग में सहमति वापस नहीं ली जा सकती; प्रश्नों के लिए ऐप व्यवस्थापक से संपर्क करें। वैकल्पिक मार्केटिंग सहमति स्वास्थ्य सुविधाओं की सहमति से अलग है।",
"notFoundTitle": "404 - नहीं मिला",
"goHome": "होम पर जाएँ",
"chooseLanguage": "अपनी भाषा चुनें",
Expand Down
14 changes: 14 additions & 0 deletions frontend/public/locales/zh/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,20 @@
"optional": "可选",
"home": "首页",
"accountSettings": "账户设置",
"privacyControlsTitle": "隐私与您的数据",
"privacyExportTitle": "导出您的数据",
"privacyExportDescription": "下载包含个人资料、同意记录、已提交问卷和已保存风险评估的 JSON 副本。导出内容仅限您的账户,应用不会单独保存该文件。",
"privacyExportButton": "下载我的数据",
"privacyExportLoading": "正在准备导出...",
"privacyExportSuccess": "您的数据已导出并下载。",
"privacyExportEmpty": "导出文件已下载。您目前没有已保存的问卷或风险评估记录。",
"privacyExportError": "无法导出您的数据。请检查网络连接后重试。",
"privacyRetentionTitle": "数据保留与删除",
"privacyRetentionDescription": "应用目前未公布自动保留期限,也不提供自助删除功能。如需删除账户数据,请联系应用管理员。下载的导出文件保存在您的设备上,应用不会删除这些文件。",
"privacyConsentTitle": "同意设置",
"privacyConsentGranted": "您的账户目前已记录健康数据处理同意。",
"privacyConsentNotGranted": "您的账户目前未记录健康数据处理同意。",
"privacyConsentDescription": "使用健康功能需要当前有效的同意。目前无法在设置中撤回同意;如有疑问,请联系应用管理员。可选营销同意与健康功能同意分开管理。",
"notFoundTitle": "404 - 未找到",
"goHome": "返回首页",
"chooseLanguage": "选择您的语言",
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,9 @@ function App() {

<Route element={<RequireAuth />}>
<Route path="/consent" element={<ConsentPage />} />
<Route path="/settings/account" element={<Account />} />
<Route element={<RequireHealthConsent />}>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/settings/account" element={<Account />} />
<Route path="/modules/heart-risk" element={<HeartRisk />} />
<Route path="/modules/framingham-risk" element={<FraminghamRisk />} />
<Route path="/modules/prescription" element={<Prescription />} />
Expand Down
Loading
Loading