Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
16 changes: 15 additions & 1 deletion registry/batch/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,21 @@ def validate(self, **kwargs):
)

if self.invalid_lines_content:
self.valid_lines.data = "\n".join(self.valid_lines_content)
# Normalize valid lines to show only RC, email, and phone for easier review
from .utils import process_contact_import_line

normalized_valid_lines = []
for line in self.valid_lines_content:
data = process_contact_import_line(line)
# Reconstruct line with only the parsed data
parts = [data["rodne_cislo"]]
if data["email"]:
parts.append(data["email"])
if data["phone"]:
parts.append(data["phone"])
normalized_valid_lines.append(" ".join(parts))

self.valid_lines.data = "\n".join(normalized_valid_lines)
self.invalid_lines.data = ""
self.invalid_lines_errors.data = ""
for line, errors in self.invalid_lines_content:
Expand Down
25 changes: 23 additions & 2 deletions registry/donor/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -164,9 +164,30 @@ def dict_for_frontend(self):
donor_dict[name] = getattr(self, name, None)
# Note is special because note column contains
# Note object but we need to get its text which
# is in Note.note attr.
# is in Note.note attr. We send it as structured data
# so the frontend can display different icons for different types.
if donor_dict[name] is not None and name == "note":
donor_dict[name] = donor_dict[name].note
note_obj = donor_dict[name]
emails = note_obj.get_emails_from_note()
phones = note_obj.get_phones_from_note()

# Calculate "other text" by removing emails and phones
other_text = note_obj.note
for email in emails:
other_text = other_text.replace(email, "")
for phone in phones:
other_text = other_text.replace(phone, "")
# Clean up whitespace
other_text = "\n".join(
line.strip() for line in other_text.split("\n") if line.strip()
)

donor_dict[name] = {
"emails": emails,
"phones": phones,
"other": other_text,
"raw": note_obj.note, # Keep full text for fallback
}
elif donor_dict[name] is not None and name in (
"first_name",
"last_name",
Expand Down
27 changes: 24 additions & 3 deletions registry/templates/donor/overview_table.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,30 @@ $(document).ready( function () {
}, {
"targets": "note",
"render": function ( data, type, row, meta ) {
if (data)
return `<span data-toggle="modal" data-target="#titleModal" title="${data}">⚠️</span>`;
return "";
if (!data) return "";

// Data is now structured: {emails: [], phones: [], other: "", raw: ""}
let icons = [];

// Add email icon(s) as mailto links
if (data.emails && data.emails.length > 0) {
data.emails.forEach(email => {
icons.push(`<a href="mailto:${email}" title="${email}">📧</a>`);
});
}

// Add phone icon(s)
if (data.phones && data.phones.length > 0) {
const phoneList = data.phones.join("\n");
icons.push(`<span data-toggle="modal" data-target="#titleModal" title="${phoneList}">📞</span>`);
}

// Add warning icon if there's other text
if (data.other && data.other.trim()) {
icons.push(`<span data-toggle="modal" data-target="#titleModal" title="${data.raw}">⚠️</span>`);
}

return icons.join(" ");
},
"orderable": false,
},
Expand Down
11 changes: 7 additions & 4 deletions tests/fixtures.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
from unittest.mock import MagicMock, patch

from flask_migrate import Migrate, upgrade
from pytest import fixture, skip
from pytest import fixture
from sqlalchemy.exc import IntegrityError
from webtest import TestApp

Expand Down Expand Up @@ -118,9 +118,12 @@ def sample_of_rc(amount=100):
yield sample(list(test_data[test_data.MISTO_ODBERU == dc].RC.unique()), 1)[0]


def skip_if_ignored(rodne_cislo):
if _db.session.get(IgnoredDonors, rodne_cislo):
skip("Donor is ignored")
def new_rc_if_ignored(rodne_cislo):
while True:
if _db.session.get(IgnoredDonors, rodne_cislo):
rodne_cislo = next(sample_of_rc(1))
continue
return rodne_cislo


@fixture(scope="session", autouse=True)
Expand Down
77 changes: 74 additions & 3 deletions tests/test_contact_import.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
from registry.extensions import db
from registry.utils import EMAIL_RE, PHONE_RE, RC_RE, is_valid_rc

from .fixtures import delete_note_if_exists, sample_of_rc
from .fixtures import delete_note_if_exists, new_rc_if_ignored, sample_of_rc
from .helpers import login


Expand Down Expand Up @@ -108,6 +108,7 @@ class TestParseContactLine:
@pytest.mark.parametrize("rc", sample_of_rc(2))
def test_valid_line_with_all_fields(self, rc):
"""Test parsing a line with RC, email, and phone."""
rc = new_rc_if_ignored(rc)
line = f"{rc} jan.novak@seznam.cz 602123456"
parsed_rc, email, phone, errors = parse_contact_line(line)

Expand All @@ -119,6 +120,7 @@ def test_valid_line_with_all_fields(self, rc):
@pytest.mark.parametrize("rc", sample_of_rc(2))
def test_valid_line_different_order(self, rc):
"""Test that order doesn't matter."""
rc = new_rc_if_ignored(rc)
line = f"{rc} jan.novak@seznam.cz 602123456"
parsed_rc, email, phone, errors = parse_contact_line(line)

Expand All @@ -130,6 +132,7 @@ def test_valid_line_different_order(self, rc):
@pytest.mark.parametrize("rc", sample_of_rc(2))
def test_valid_line_with_slash_rc(self, rc):
"""Test RC with slash separator."""
rc = new_rc_if_ignored(rc)
original_rc = rc
rc = rc[:6] + "/" + rc[6:]
line = f"{rc} test@email.cz +420 602 123 456"
Expand All @@ -143,6 +146,7 @@ def test_valid_line_with_slash_rc(self, rc):
@pytest.mark.parametrize("rc", sample_of_rc(2))
def test_valid_line_email_only(self, rc):
"""Test line with only RC and email."""
rc = new_rc_if_ignored(rc)
line = f"{rc} marie.nova@gmail.com"
parsed_rc, email, phone, errors = parse_contact_line(line)

Expand All @@ -154,6 +158,7 @@ def test_valid_line_email_only(self, rc):
@pytest.mark.parametrize("rc", sample_of_rc(2))
def test_valid_line_phone_only(self, rc):
"""Test line with only RC and phone."""
rc = new_rc_if_ignored(rc)
line = f"{rc} +420734000000"
parsed_rc, email, phone, errors = parse_contact_line(line)

Expand All @@ -165,6 +170,7 @@ def test_valid_line_phone_only(self, rc):
@pytest.mark.parametrize("rc", sample_of_rc(2))
def test_complex_line_with_extra_text(self, rc):
"""Test parsing complex line with extra text (like from export)."""
rc = new_rc_if_ignored(rc)
line = f"DANIEL DOLEŽAL {rc} 2004-07-15 00:00:00 213 DOLNÍ LOMNÁ 203 73991 JABLUNKOV +420 734000000 a@seznam.cz 2025-10-03 00:00:00"
parsed_rc, email, phone, errors = parse_contact_line(line)

Expand All @@ -184,6 +190,7 @@ def test_missing_rc(self):
@pytest.mark.parametrize("rc", sample_of_rc(2))
def test_missing_contact_info(self, rc):
"""Test error when both email and phone are missing."""
rc = new_rc_if_ignored(rc)
line = rc
parsed_rc, email, phone, errors = parse_contact_line(line)

Expand Down Expand Up @@ -237,6 +244,9 @@ class TestValidateContactImportData:
def test_valid_lines(self):
"""Test validation with all valid lines."""
rc1, rc2, rc3 = sample_of_rc(3)
rc1 = new_rc_if_ignored(rc1)
rc2 = new_rc_if_ignored(rc2)
rc3 = new_rc_if_ignored(rc3)
text = f"""{rc1} jan.novak@seznam.cz 602123456
{rc2[:6] + "/" + rc2[6:]} marie.nova@gmail.com
{rc3} +420734000000"""
Expand All @@ -249,6 +259,9 @@ def test_valid_lines(self):
def test_mixed_valid_invalid(self):
"""Test validation with mix of valid and invalid lines."""
rc1, rc2, rc3 = sample_of_rc(3)
rc1 = new_rc_if_ignored(rc1)
rc2 = new_rc_if_ignored(rc2)
rc3 = new_rc_if_ignored(rc3)
text = f"""{rc1} jan.novak@seznam.cz 602123456
just-email@test.cz
{rc2} +420734000000
Expand All @@ -268,6 +281,8 @@ def test_mixed_valid_invalid(self):
def test_empty_lines_skipped(self):
"""Test that empty lines are silently skipped."""
rc1, rc2 = sample_of_rc(2)
rc1 = new_rc_if_ignored(rc1)
rc2 = new_rc_if_ignored(rc2)
text = f"""{rc1} test@email.cz

{rc2} +420734000000
Expand Down Expand Up @@ -295,6 +310,7 @@ class TestProcessContactImportLine:
@pytest.mark.parametrize("rc", sample_of_rc(2))
def test_process_valid_line(self, rc):
"""Test processing a valid line returns structured data."""
rc = new_rc_if_ignored(rc)
line = f"{rc} jan.novak@seznam.cz 602123456"
data = process_contact_import_line(line)

Expand All @@ -305,6 +321,7 @@ def test_process_valid_line(self, rc):
@pytest.mark.parametrize("rc", sample_of_rc(2))
def test_process_line_with_missing_optional_fields(self, rc):
"""Test processing line with only some fields."""
rc = new_rc_if_ignored(rc)
line = f"{rc} test@email.cz"
data = process_contact_import_line(line)

Expand Down Expand Up @@ -386,6 +403,7 @@ class TestContactImportIntegration:
@pytest.mark.parametrize("rc", sample_of_rc(2))
def test_simple_contact_import(self, user, testapp, rc):
"""Test importing a simple contact."""
rc = new_rc_if_ignored(rc)
login(user, testapp)

# Navigate to import page
Expand All @@ -412,6 +430,7 @@ def test_simple_contact_import(self, user, testapp, rc):
@pytest.mark.parametrize("rc", sample_of_rc(2))
def test_contact_import_duplicate_detection(self, user, testapp, rc):
"""Test that duplicate contacts are not added."""
rc = new_rc_if_ignored(rc)
login(user, testapp)

delete_note_if_exists(rc)
Expand Down Expand Up @@ -439,6 +458,7 @@ def test_contact_import_duplicate_detection(self, user, testapp, rc):
@pytest.mark.parametrize("rc", sample_of_rc(2))
def test_contact_import_with_errors(self, user, testapp, rc):
"""Test import with invalid lines shows errors."""
rc = new_rc_if_ignored(rc)
login(user, testapp)

res = testapp.get(url_for("batch.import_contacts"))
Expand All @@ -465,6 +485,7 @@ def test_contact_import_with_errors(self, user, testapp, rc):
@pytest.mark.parametrize("rc", sample_of_rc(2))
def test_contact_import_creates_new_note(self, user, testapp, rc):
"""Test that import creates new note if none exists."""
rc = new_rc_if_ignored(rc)
login(user, testapp)

delete_note_if_exists(rc)
Expand All @@ -485,6 +506,7 @@ def test_contact_import_creates_new_note(self, user, testapp, rc):
@pytest.mark.parametrize("rc", sample_of_rc(2))
def test_contact_import_creates_new_note_with_phone(self, user, testapp, rc):
"""Test that import creates new note if none exists."""
rc = new_rc_if_ignored(rc)
login(user, testapp)

delete_note_if_exists(rc)
Expand All @@ -505,6 +527,7 @@ def test_contact_import_creates_new_note_with_phone(self, user, testapp, rc):
@pytest.mark.parametrize("rc", sample_of_rc(2))
def test_contact_import_appends_to_existing_note(self, user, testapp, rc):
"""Test that new contacts are appended to existing note."""
rc = new_rc_if_ignored(rc)
login(user, testapp)

delete_note_if_exists(rc)
Expand All @@ -531,6 +554,7 @@ def test_contact_import_appends_to_existing_note(self, user, testapp, rc):
@pytest.mark.parametrize("rc", sample_of_rc(2))
def test_contact_import_empty_input(self, user, testapp, rc):
"""Test that empty input is ignored."""
rc = new_rc_if_ignored(rc)
login(user, testapp)

note_before = Note.query.get(rc)
Expand All @@ -555,6 +579,7 @@ class TestNoteModelMethods:
@pytest.mark.parametrize("rc", sample_of_rc(2))
def test_get_phones_from_note(self, db, rc):
"""Test extracting phone numbers from note."""
rc = new_rc_if_ignored(rc)
delete_note_if_exists(rc)

note = Note(
Expand All @@ -572,6 +597,7 @@ def test_get_phones_from_note(self, db, rc):
@pytest.mark.parametrize("rc", sample_of_rc(2))
def test_get_all_contacts(self, db, rc):
"""Test getting all contacts from note."""
rc = new_rc_if_ignored(rc)
delete_note_if_exists(rc)

note = Note(
Expand Down Expand Up @@ -712,6 +738,7 @@ class TestContactImportErrorFixRetry:
@pytest.mark.parametrize("rc", sample_of_rc(2))
def test_import_with_errors_then_fix_and_retry(self, user, testapp, rc, db):
"""Test full workflow: submit invalid data, see errors, fix them, retry successfully."""
rc = new_rc_if_ignored(rc)
login(user, testapp)

delete_note_if_exists(rc)
Expand Down Expand Up @@ -764,6 +791,44 @@ def test_import_with_errors_then_fix_and_retry(self, user, testapp, rc, db):
assert "jan.novak@seznam.cz" in note.note
assert "602123456" in note.note

def test_valid_lines_normalized_when_errors_shown(self, user, testapp):
"""Test that valid lines are cleaned up to show only RC, email, and phone."""
login(user, testapp)

rc = next(sample_of_rc(1))
rc = new_rc_if_ignored(rc)

# Submit data with extra text in valid line
res = testapp.get(url_for("batch.import_contacts"))
form = res.forms["contactImportForm"]

# Valid line has extra text, invalid line is missing RC
form[
"input_data"
] = f"""JOHN DOE {rc} born 1990-01-01 contact: jan.novak@seznam.cz or phone 602123456 address: somewhere
just-email@test.cz"""

res = form.submit()

# Check that errors are shown
assert "Řádky s chybami" in res
assert "chybí rodné číslo" in res

# Check that valid line is normalized (extra text removed)
assert "Validní řádky" in res
form = res.forms["contactImportForm"]
valid_lines = form["valid_lines"].value

# Should contain only RC, email, and phone
assert rc in valid_lines
assert "jan.novak@seznam.cz" in valid_lines
assert "602123456" in valid_lines

# Should NOT contain the extra text
assert "JOHN DOE" not in valid_lines
assert "born 1990-01-01" not in valid_lines
assert "somewhere" not in valid_lines


class TestContactImportAuditLog:
"""Test audit logging for contact imports."""
Expand All @@ -775,7 +840,9 @@ def test_audit_log_created_with_correct_statistics(self, user, testapp, db):
login(user, testapp)

rc1, rc2, rc3 = sample_of_rc(3)

rc1 = new_rc_if_ignored(rc1)
rc2 = new_rc_if_ignored(rc2)
rc3 = new_rc_if_ignored(rc3)
for rc in rc1, rc2, rc3:
delete_note_if_exists(rc)

Expand Down Expand Up @@ -848,7 +915,9 @@ def test_audit_log_with_filename_from_file_upload(self, user, testapp, tmp_path)
login(user, testapp)

rc1, rc2, rc3 = sample_of_rc(3)

rc1 = new_rc_if_ignored(rc1)
rc2 = new_rc_if_ignored(rc2)
rc3 = new_rc_if_ignored(rc3)
# Create temporary file with the data
temp_file = tmp_path / "test_contacts.txt"
temp_file.write_text(
Expand Down Expand Up @@ -887,6 +956,7 @@ def test_audit_log_with_duplicate_contacts_skipped(self, user, testapp, rc, db):

login(user, testapp)

rc = new_rc_if_ignored(rc)
delete_note_if_exists(rc)

# Create existing note with contacts
Expand Down Expand Up @@ -921,6 +991,7 @@ def test_audit_log_view_displays_input_data_in_modal(self, user, testapp, rc):
login(user, testapp)

delete_note_if_exists(rc)
rc = new_rc_if_ignored(rc)

# Perform import
test_input = f"{rc} test@email.cz 602123456"
Expand Down
Loading