Merge branch 'develop' into multi-church

This commit is contained in:
meichthys 2026-03-08 04:08:27 +00:00
commit fcbe8b3565
18 changed files with 1185 additions and 206 deletions

View File

@ -1,7 +1,7 @@
// Copyright (c) 2026, meichthys and contributors
// For license information, please see license.txt
// frappe.ui.form.on("Church Location", {
// frappe.ui.form.on("Church", {
// refresh(frm) {
// },

View File

@ -4,17 +4,17 @@
"allow_rename": 1,
"autoname": "field:church_name",
"creation": "2025-09-17 21:10:06.782386",
"default_view": "Tree",
"default_view": "List",
"description": "A church or church branch.",
"doctype": "DocType",
"engine": "InnoDB",
"field_order": [
"church_name",
"legal_name",
"founding_date",
"column_break_rmsm",
"is_group",
"parent_church",
"column_break_rmsm",
"founding_date",
"address",
"default_bible_translation",
"mission_statement",
@ -32,7 +32,8 @@
"in_list_view": 1,
"in_standard_filter": 1,
"label": "Name",
"reqd": 1
"reqd": 1,
"unique": 1
},
{
"fieldname": "legal_name",
@ -60,6 +61,7 @@
"fieldname": "parent_church",
"fieldtype": "Link",
"ignore_user_permissions": 1,
"in_list_view": 1,
"label": "Parent Church",
"options": "Church"
},
@ -123,7 +125,7 @@
"index_web_pages_for_search": 1,
"is_tree": 1,
"links": [],
"modified": "2025-11-15 22:53:46.638825",
"modified": "2026-03-07 17:02:03.356563",
"modified_by": "Administrator",
"module": "Church Foundations",
"name": "Church",

View File

@ -1,149 +0,0 @@
{
"actions": [],
"allow_import": 1,
"allow_rename": 1,
"autoname": "format:{title}",
"creation": "2026-01-05 23:28:09.404374",
"default_view": "Tree",
"description": "A physical location associated with the Church. (i.e. Office, Library, etc)",
"doctype": "DocType",
"engine": "InnoDB",
"field_order": [
"church",
"title",
"notes",
"column_break_djot",
"parent_church_location",
"is_group",
"photo",
"hidden_fields_section",
"lft",
"rgt",
"old_parent"
],
"fields": [
{
"fieldname": "church",
"fieldtype": "Link",
"in_list_view": 1,
"in_standard_filter": 1,
"label": "Church",
"options": "Church",
"search_index": 1
},
{
"allow_in_quick_entry": 1,
"fieldname": "title",
"fieldtype": "Data",
"in_list_view": 1,
"in_preview": 1,
"in_standard_filter": 1,
"label": "Title",
"reqd": 1
},
{
"allow_in_quick_entry": 1,
"fieldname": "notes",
"fieldtype": "Text Editor",
"in_list_view": 1,
"in_preview": 1,
"label": "Notes"
},
{
"fieldname": "lft",
"fieldtype": "Int",
"hidden": 1,
"label": "Left",
"no_copy": 1,
"read_only": 1
},
{
"fieldname": "rgt",
"fieldtype": "Int",
"hidden": 1,
"label": "Right",
"no_copy": 1,
"read_only": 1
},
{
"default": "0",
"description": "Check this if other locations are located within this location.",
"fieldname": "is_group",
"fieldtype": "Check",
"label": "Is Group"
},
{
"fieldname": "old_parent",
"fieldtype": "Link",
"label": "Old Parent",
"options": "Church Location"
},
{
"description": "If this location is located within another location, choose the other location here.",
"fieldname": "parent_church_location",
"fieldtype": "Link",
"ignore_user_permissions": 1,
"label": "Parent Location",
"options": "Church Location"
},
{
"fieldname": "column_break_djot",
"fieldtype": "Column Break"
},
{
"fieldname": "hidden_fields_section",
"fieldtype": "Section Break",
"hidden": 1,
"label": "Hidden Fields"
},
{
"fieldname": "photo",
"fieldtype": "Attach Image",
"label": "Photo"
}
],
"grid_page_length": 50,
"image_field": "photo",
"index_web_pages_for_search": 1,
"is_tree": 1,
"links": [],
"modified": "2026-01-05 23:51:38.123146",
"modified_by": "Administrator",
"module": "Church Operations",
"name": "Church Location",
"naming_rule": "Expression",
"nsm_parent_field": "parent_church_location",
"owner": "Administrator",
"permissions": [
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "System Manager",
"share": 1,
"write": 1
},
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"import": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "Church Manager",
"share": 1,
"write": 1
}
],
"quick_entry": 1,
"row_format": "Dynamic",
"sort_field": "modified",
"sort_order": "DESC",
"states": []
}

View File

@ -1,9 +0,0 @@
# Copyright (c) 2026, meichthys and contributors
# For license information, please see license.txt
# import frappe
from frappe.model.document import Document
class ChurchLocation(Document):
pass

View File

@ -1,9 +0,0 @@
# Copyright (c) 2026, meichthys and Contributors
# See license.txt
# import frappe
from frappe.tests.utils import FrappeTestCase
class TestChurchLocation(FrappeTestCase):
pass

View File

@ -17,7 +17,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Operations Documents",
"link_count": 3,
"link_count": 2,
"link_type": "DocType",
"onboard": 0,
"type": "Card Break"
@ -32,16 +32,6 @@
"onboard": 0,
"type": "Link"
},
{
"hidden": 0,
"is_query_report": 0,
"label": "Church Locations",
"link_count": 0,
"link_to": "Church Location",
"link_type": "DocType",
"onboard": 0,
"type": "Link"
},
{
"hidden": 0,
"is_query_report": 0,

View File

@ -0,0 +1,34 @@
{
"based_on": "membership_date",
"chart_name": "Members",
"chart_type": "Sum",
"creation": "2026-03-07 15:58:56.308560",
"currency": "USD",
"docstatus": 0,
"doctype": "Dashboard Chart",
"document_type": "Person",
"dynamic_filters_json": "[]",
"filters_json": "[[\"Person\",\"is_member\",\"=\",1,false]]",
"group_by_type": "Count",
"idx": 0,
"is_public": 0,
"is_standard": 1,
"last_synced_on": "2026-03-07 16:00:38.065515",
"modified": "2026-03-07 16:01:28.327322",
"modified_by": "Administrator",
"module": "Church People",
"name": "Members",
"number_of_groups": 0,
"owner": "Administrator",
"parent_document_type": "",
"roles": [],
"show_values_over_chart": 1,
"source": "",
"time_interval": "Monthly",
"timeseries": 1,
"timespan": "Last Year",
"type": "Line",
"use_report_chart": 0,
"value_based_on": "",
"y_axis": []
}

View File

@ -0,0 +1,33 @@
{
"based_on": "creation",
"chart_name": "People",
"chart_type": "Sum",
"creation": "2026-03-07 15:56:57.922304",
"currency": "USD",
"docstatus": 0,
"doctype": "Dashboard Chart",
"document_type": "Person",
"dynamic_filters_json": "[]",
"filters_json": "[]",
"group_by_type": "Count",
"idx": 0,
"is_public": 0,
"is_standard": 1,
"modified": "2026-03-07 15:56:57.922304",
"modified_by": "Administrator",
"module": "Church People",
"name": "People",
"number_of_groups": 0,
"owner": "Administrator",
"parent_document_type": "",
"roles": [],
"show_values_over_chart": 1,
"source": "",
"time_interval": "Weekly",
"timeseries": 1,
"timespan": "Last Year",
"type": "Line",
"use_report_chart": 0,
"value_based_on": "",
"y_axis": []
}

View File

@ -8,7 +8,8 @@
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"member"
"member",
"relationship_to_head"
],
"fields": [
{
@ -19,6 +20,13 @@
"label": "Family Member",
"options": "Person",
"reqd": 1
},
{
"fieldname": "relationship_to_head",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Relationship to Head",
"options": "Person Relation Type"
}
],
"grid_page_length": 50,

View File

@ -47,7 +47,6 @@
"anniversary",
"spouse",
"column_break_fkwj",
"relationships",
"notes_tab",
"notes"
],
@ -278,15 +277,6 @@
"label": "Notes",
"print_hide": 1
},
{
"allow_in_quick_entry": 1,
"description": "Note: Relations are not automatically reciprocal. (i.e. If you are editing 'Mark' and add 'Jane' as 'Sister', Jane's record will not automatically be updated to show 'Mark' as 'Brother'. ",
"fieldname": "relationships",
"fieldtype": "Table",
"label": "Notable Relationships",
"options": "Person Relation",
"print_hide": 1
},
{
"allow_in_quick_entry": 1,
"default": "Unknown",

View File

@ -113,9 +113,7 @@ class Person(Document):
@frappe.whitelist()
def new_family_from_person(self):
# Check if a family with this person's name already exists
existing_family = frappe.db.exists(
"Family", {"family_name": f"{self.last_name} - {self.first_name}"}
)
existing_family = frappe.db.exists("Family", {"family_name": f"{self.last_name} - {self.first_name}"})
if existing_family:
# Set this person's family to the existing one

View File

@ -11,8 +11,8 @@
"idx": 0,
"is_public": 0,
"is_standard": 1,
"label": "Church Member Count",
"modified": "2025-09-21 22:43:41.991451",
"label": "Member Count",
"modified": "2026-03-07 15:50:10.752238",
"modified_by": "Administrator",
"module": "Church People",
"name": "Church Members",

View File

@ -0,0 +1,525 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>{{ church.church_name }} Church Directory</title>
<style>
/* ── Page setup ─────────────────────────────────────────────── */
@page {
size: letter;
margin: 18mm 18mm 18mm 18mm;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: Georgia, "Times New Roman", serif;
font-size: 11pt;
color: #1a1a2e;
background: #fff;
}
/* ── Cover page ─────────────────────────────────────────────── */
.cover {
break-after: page;
page-break-after: always;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 100vh;
text-align: center;
padding: 40mm 20mm;
}
.cover-church-name {
font-size: 30pt;
font-weight: bold;
color: #1f3c88;
letter-spacing: 1px;
margin-bottom: 6px;
}
.cover-legal-name {
font-size: 14pt;
color: #555;
margin-bottom: 12px;
}
.cover-address {
font-size: 12pt;
color: #444;
margin-bottom: 30px;
line-height: 1.5;
}
.cover-title {
font-size: 24pt;
letter-spacing: 3px;
text-transform: uppercase;
color: #1f3c88;
border-top: 2px solid #1f3c88;
border-bottom: 2px solid #1f3c88;
padding: 10px 30px;
margin-bottom: 20px;
}
.cover-date {
font-size: 13pt;
color: #666;
margin-bottom: 30px;
}
.cover-mission {
font-size: 12pt;
font-style: italic;
color: #444;
max-width: 420px;
line-height: 1.6;
border-left: 3px solid #1f3c88;
padding-left: 14px;
text-align: left;
margin-top: 20px;
}
/* ── Directory section header ───────────────────────────────── */
.alpha-divider {
font-size: 18pt;
font-weight: bold;
color: #1f3c88;
border-bottom: 2px solid #1f3c88;
margin: 14px 0 8px 0;
padding-bottom: 2px;
break-after: avoid;
page-break-after: avoid;
}
/* ── Family block ───────────────────────────────────────────── */
.family-block {
break-inside: avoid;
page-break-inside: avoid;
border: 1px solid #d0d8e8;
border-left: 4px solid #1f3c88;
border-radius: 4px;
padding: 8px 12px;
margin-bottom: 8px;
background: #f9fafd;
}
.family-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 3px;
}
.family-name {
font-size: 13pt;
font-weight: bold;
color: #1f3c88;
}
.family-church-label {
font-size: 8.5pt;
color: #888;
font-style: italic;
margin-top: 1px;
}
/* Right side of family header: address + optional photo */
.family-meta {
display: flex;
align-items: flex-start;
gap: 10px;
}
.family-address {
font-size: 9.5pt;
color: #555;
text-align: right;
line-height: 1.4;
}
.family-photo-img {
height: 150px;
width: auto;
object-fit: cover;
border-radius: 6px;
border: 2px solid #d0d8e8;
display: block;
}
/* ── Member table ───────────────────────────────────────────── */
.member-table {
width: 100%;
border-collapse: collapse;
margin-top: 4px;
font-size: 10pt;
}
.member-table thead tr {
border-bottom: 1px solid #c5cfe0;
}
.member-table th {
font-size: 8.5pt;
font-weight: normal;
color: #888;
text-transform: uppercase;
letter-spacing: 0.4px;
padding: 2px 6px 3px 0;
text-align: left;
}
.member-table td {
padding: 3px 6px 3px 0;
vertical-align: middle;
color: #222;
border-bottom: 1px dotted #e4e8f0;
}
.member-table tr:last-child td {
border-bottom: none;
}
.col-photo { width: 58px; padding-right: 4px !important; }
.col-name { width: 30%; font-weight: 600; }
.col-membership { width: 18%; }
.col-phone { width: 22%; }
.col-email { width: 26%; word-break: break-all; }
.member-photo {
width: 50px;
height: 50px;
object-fit: cover;
border-radius: 50%;
display: block;
}
.hoh-star {
color: #1f3c88;
font-size: 9pt;
margin-left: 3px;
}
.status-badge {
display: inline-block;
font-size: 8.5pt;
padding: 1px 6px;
border-radius: 10px;
background: #e8edf8;
color: #1f3c88;
white-space: nowrap;
}
.member-roles {
font-size: 8pt;
color: #666;
font-style: italic;
margin-top: 1px;
}
/* ── Appendix sections (birthdays, anniversaries, missionaries) */
.appendix-section {
break-before: page;
page-break-before: always;
}
.appendix-heading {
font-size: 20pt;
font-weight: bold;
color: #1f3c88;
border-bottom: 2px solid #1f3c88;
margin-bottom: 12px;
padding-bottom: 4px;
}
.month-divider {
font-size: 13pt;
font-weight: bold;
color: #1f3c88;
margin: 14px 0 4px 0;
padding-bottom: 2px;
border-bottom: 1px solid #d0d8e8;
break-after: avoid;
page-break-after: avoid;
}
.calendar-row {
display: flex;
justify-content: space-between;
padding: 4px 8px;
border-bottom: 1px dotted #e4e8f0;
font-size: 10pt;
}
.calendar-row:last-child {
border-bottom: none;
}
.calendar-name {
color: #222;
}
.calendar-date {
color: #555;
font-style: italic;
}
/* ── Missionary cards ───────────────────────────────────────── */
.missionary-block {
break-inside: avoid;
page-break-inside: avoid;
border: 1px solid #d0d8e8;
border-left: 4px solid #1f3c88;
border-radius: 4px;
padding: 10px 12px;
margin-bottom: 10px;
background: #f9fafd;
}
.missionary-header {
display: flex;
gap: 14px;
align-items: flex-start;
}
.missionary-photo {
width: 80px;
height: 80px;
object-fit: cover;
border-radius: 6px;
border: 2px solid #d0d8e8;
flex-shrink: 0;
}
.missionary-info {
flex: 1;
}
.missionary-title {
font-size: 13pt;
font-weight: bold;
color: #1f3c88;
}
.missionary-meta {
font-size: 10pt;
color: #555;
margin-top: 2px;
}
.missionary-statement {
font-size: 9.5pt;
color: #444;
font-style: italic;
margin-top: 5px;
line-height: 1.5;
}
.missionary-contact {
font-size: 9.5pt;
color: #444;
margin-top: 6px;
line-height: 1.6;
word-break: break-all;
}
.sensitive-badge {
display: inline-block;
font-size: 8pt;
padding: 1px 8px;
border-radius: 10px;
background: #fff3cd;
color: #856404;
margin-top: 3px;
}
/* ── Footer ─────────────────────────────────────────────────── */
@page {
@bottom-center {
content: "{{ church.church_name }} Church Directory {{ generated_date }}";
font-family: Georgia, serif;
font-size: 8pt;
color: #999;
}
}
</style>
</head>
<body>
<!-- ════════════════════════════════════════════════════════════
COVER PAGE
═════════════════════════════════════════════════════════════ -->
<div class="cover">
<div class="cover-church-name">{{ church.church_name }}</div>
{% if church.legal_name and church.legal_name != church.church_name %}
<div class="cover-legal-name">{{ church.legal_name }}</div>
{% endif %}
{% if church_address %}
<div class="cover-address">
{{ church_address.address_line1 }}{% if church_address.address_line2 %}<br>{{ church_address.address_line2 }}{% endif %}<br>
{{ church_address.city }}{% if church_address.state %}, {{ church_address.state }}{% endif %}{% if church_address.pincode %} {{ church_address.pincode }}{% endif %}
</div>
{% endif %}
<div class="cover-title">Church Directory</div>
<div class="cover-date">{{ generated_date }}</div>
{% if church.mission_statement %}
<div class="cover-mission">{{ church.mission_statement }}</div>
{% endif %}
</div>
<!-- ════════════════════════════════════════════════════════════
DIRECTORY (families + individuals merged alphabetically)
═════════════════════════════════════════════════════════════ -->
{% set ns = namespace(current_letter='') %}
{% for entry in all_entries %}
{% set first_letter = (entry.sort_name[0] if entry.sort_name else '#').upper() %}
{% if first_letter != ns.current_letter %}
{% set ns.current_letter = first_letter %}
<div class="alpha-divider">{{ first_letter }}</div>
{% endif %}
<div class="family-block">
<div class="family-header">
<!-- Left: name + optional sub-church label -->
<div>
<div class="family-name">{{ entry.display_name }}</div>
{% if show_church_label %}<div class="family-church-label">{{ entry.church_name }}</div>{% endif %}
</div>
<!-- Right: address + optional family photo -->
<div class="family-meta">
{% if entry.address_line1 or entry.city %}
<div class="family-address">
{% if entry.address_line1 %}{{ entry.address_line1 }}{% if entry.address_line2 %}, {{ entry.address_line2 }}{% endif %}<br>{% endif %}
{% if entry.city %}{{ entry.city }}{% if entry.state %}, {{ entry.state }}{% endif %}{% if entry.pincode %} {{ entry.pincode }}{% endif %}{% endif %}
</div>
{% endif %}
{% if show_photos and entry.family_photo %}
<img class="family-photo-img" src="{{ entry.family_photo }}" alt="{{ entry.display_name }}">
{% endif %}
</div>
</div>
<table class="member-table">
<thead>
<tr>
{% if show_photos %}<th class="col-photo"></th>{% endif %}
<th class="col-name">Name</th>
{% if show_membership %}<th class="col-membership">Membership</th>{% endif %}
<th class="col-phone">Phone</th>
<th class="col-email">Email</th>
</tr>
</thead>
<tbody>
{% for member in entry.members %}
<tr>
{% if show_photos %}
<td class="col-photo">
{% if member.photo %}
<img class="member-photo" src="{{ member.photo }}" alt="">
{% endif %}
</td>
{% endif %}
<td class="col-name">
{{ member.full_name }}
{% if show_hoh and member.is_head_of_household %}<span class="hoh-star" title="Head of Household">&#9733;</span>{% endif %}
{% if show_roles and member.positions %}
<div class="member-roles">{{ member.positions | join(', ') }}</div>
{% endif %}
</td>
{% if show_membership %}
<td class="col-membership">
{% if member.membership_status %}
<span class="status-badge">{{ member.membership_status }}</span>
{% endif %}
</td>
{% endif %}
<td class="col-phone">{{ member.primary_phone or '' }}</td>
<td class="col-email">{{ member.email or '' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endfor %}
<!-- ════════════════════════════════════════════════════════════
BIRTHDAYS
═════════════════════════════════════════════════════════════ -->
{% if show_birthdays and birthdays %}
<div class="appendix-section">
<div class="appendix-heading">Birthdays</div>
{% set ns2 = namespace(current_month='') %}
{% for person in birthdays %}
{% if person.month_name != ns2.current_month %}
{% set ns2.current_month = person.month_name %}
<div class="month-divider">{{ person.month_name }}</div>
{% endif %}
<div class="calendar-row">
<span class="calendar-name">{{ person.full_name }}</span>
<span class="calendar-date">{{ person.month_day }}</span>
</div>
{% endfor %}
</div>
{% endif %}
<!-- ════════════════════════════════════════════════════════════
ANNIVERSARIES
═════════════════════════════════════════════════════════════ -->
{% if show_anniversaries and anniversaries %}
<div class="appendix-section">
<div class="appendix-heading">Anniversaries</div>
{% set ns3 = namespace(current_month='') %}
{% for couple in anniversaries %}
{% if couple.month_name != ns3.current_month %}
{% set ns3.current_month = couple.month_name %}
<div class="month-divider">{{ couple.month_name }}</div>
{% endif %}
<div class="calendar-row">
<span class="calendar-name">{{ couple.display_name }}</span>
<span class="calendar-date">{{ couple.month_day }}</span>
</div>
{% endfor %}
</div>
{% endif %}
<!-- ════════════════════════════════════════════════════════════
MISSIONARIES
═════════════════════════════════════════════════════════════ -->
{% if show_missionaries and missionaries %}
<div class="appendix-section">
<div class="appendix-heading">Missionaries</div>
{% for m in missionaries %}
<div class="missionary-block">
<div class="missionary-header">
{% if show_photos and m.photo %}
<img class="missionary-photo" src="{{ m.photo }}" alt="{{ m.title }}">
{% endif %}
<div class="missionary-info">
<div class="missionary-title">{{ m.title }}</div>
{% if m.agency %}<div class="missionary-meta">{{ m.agency }}</div>{% endif %}
{% if m.country and not m.sensitive %}<div class="missionary-meta">{{ m.country }}</div>{% endif %}
{% if m.sensitive %}<span class="sensitive-badge">Sensitive</span>{% endif %}
{% if m.mission_statement %}<div class="missionary-statement">{{ m.mission_statement }}</div>{% endif %}
</div>
</div>
{% if not m.sensitive and (m.email or m.website) %}
<div class="missionary-contact">
{% if m.email %}Email: {{ m.email }}{% endif %}
{% if m.email and m.website %}<br>{% endif %}
{% if m.website %}Website: {{ m.website }}{% endif %}
</div>
{% endif %}
</div>
{% endfor %}
</div>
{% endif %}
</body>
</html>

View File

@ -0,0 +1,115 @@
frappe.query_reports["Church Directory Report"] = {
filters: [
{
fieldname: "church",
label: __("Church"),
fieldtype: "Link",
options: "Church",
reqd: 1,
},
{
fieldname: "include_sub_churches",
label: __("Include Sub-Churches"),
fieldtype: "Check",
default: 0,
},
{
fieldname: "members_only",
label: __("Members Only"),
fieldtype: "Check",
default: 0,
},
{
fieldname: "show_photos",
label: __("Show Photos"),
fieldtype: "Check",
default: 0,
},
{
fieldname: "show_roles",
label: __("Show Positions"),
fieldtype: "Check",
default: 0,
},
{
fieldname: "show_membership",
label: __("Show Membership Status"),
fieldtype: "Check",
default: 1,
},
{
fieldname: "show_hoh",
label: __("Show Head of Household"),
fieldtype: "Check",
default: 1,
},
{
fieldname: "show_birthdays",
label: __("Include Birthday List"),
fieldtype: "Check",
default: 0,
},
{
fieldname: "show_anniversaries",
label: __("Include Anniversary List"),
fieldtype: "Check",
default: 0,
},
{
fieldname: "show_missionaries",
label: __("Include Missionaries"),
fieldtype: "Check",
default: 0,
},
],
onload: function (report) {
report.page.add_inner_button(__('Print Directory'), function () {
const church = report.get_filter_value('church');
const include_sub_churches = report.get_filter_value('include_sub_churches') ? 1 : 0;
const members_only = report.get_filter_value('members_only') ? 1 : 0;
const show_photos = report.get_filter_value('show_photos') ? 1 : 0;
const show_roles = report.get_filter_value('show_roles') ? 1 : 0;
const show_membership = report.get_filter_value('show_membership') ? 1 : 0;
const show_hoh = report.get_filter_value('show_hoh') ? 1 : 0;
const show_birthdays = report.get_filter_value('show_birthdays') ? 1 : 0;
const show_anniversaries = report.get_filter_value('show_anniversaries') ? 1 : 0;
const show_missionaries = report.get_filter_value('show_missionaries') ? 1 : 0;
if (!church) {
frappe.msgprint(__('Please select a Church first.'));
return;
}
frappe.call({
method: 'church.church_people.report.church_directory_report.church_directory_report.get_directory_html',
args: {
church,
include_sub_churches,
members_only,
show_photos,
show_roles,
show_membership,
show_hoh,
show_birthdays,
show_anniversaries,
show_missionaries,
},
freeze: true,
freeze_message: __('Generating Church Directory\u2026'),
callback: function (r) {
if (!r.message) return;
const win = window.open('', '_blank');
win.document.open();
win.document.write(r.message);
win.document.close();
// Allow images/fonts to load before triggering print
win.addEventListener('load', function () {
win.focus();
win.print();
});
},
});
});
},
};

View File

@ -0,0 +1,35 @@
{
"add_total_row": 0,
"add_translate_data": 0,
"columns": [],
"creation": "2026-03-08 00:00:00.000000",
"disabled": 0,
"docstatus": 0,
"doctype": "Report",
"filters": [],
"idx": 0,
"is_standard": "Yes",
"json": "{}",
"letterhead": null,
"modified": "2026-03-08 00:00:00.000000",
"modified_by": "Administrator",
"module": "Church People",
"name": "Church Directory Report",
"owner": "Administrator",
"prepared_report": 0,
"ref_doctype": "Family",
"report_name": "Church Directory Report",
"report_type": "Script Report",
"roles": [
{
"role": "System Manager"
},
{
"role": "Church Manager"
},
{
"role": "Church User"
}
],
"timeout": 0
}

View File

@ -0,0 +1,395 @@
import calendar
import os
import frappe
from frappe.utils import today as frappe_today
def execute(filters=None):
return get_columns(), get_data(filters)
def get_columns():
return [
{"fieldname": "family", "fieldtype": "Link", "label": "Family", "options": "Family", "width": 200},
{
"fieldname": "head_of_household",
"fieldtype": "Link",
"label": "Head of Household",
"options": "Person",
"width": 180,
},
{"fieldname": "church", "fieldtype": "Link", "label": "Church", "options": "Church", "width": 160},
{"fieldname": "city", "fieldtype": "Data", "label": "City", "width": 140},
{"fieldname": "state", "fieldtype": "Data", "label": "State", "width": 100},
{"fieldname": "member_count", "fieldtype": "Int", "label": "Members", "width": 80},
]
def get_data(filters):
if not filters or not filters.get("church"):
return []
church = filters.get("church")
members_only = frappe.utils.cint(filters.get("members_only", 0))
include_sub_churches = frappe.utils.cint(filters.get("include_sub_churches", 0))
churches = get_church_scope(church, include_sub_churches)
church_in = build_in_clause(churches)
families = frappe.db.sql(
f"""
SELECT
f.name AS family_id,
f.family_name,
f.church,
COALESCE(a.city, '') AS city,
COALESCE(a.state, '') AS state
FROM `tabFamily` f
LEFT JOIN `tabAddress` a ON a.name = f.home_address
WHERE f.church IN {church_in}
ORDER BY f.family_name ASC
""",
as_dict=True,
)
member_filter = "AND p.is_member = 1" if members_only else ""
all_members = frappe.db.sql(
f"""
SELECT p.name, p.full_name, p.family, p.is_head_of_household
FROM `tabPerson` p
WHERE p.church IN {church_in}
AND p.family IS NOT NULL AND p.family != ''
{member_filter}
""",
as_dict=True,
)
members_by_family = {}
for m in all_members:
members_by_family.setdefault(m.family, []).append(m)
result = []
for family in families:
members = members_by_family.get(family.family_id, [])
head = next((m.name for m in members if m.is_head_of_household), None)
if not head and members:
head = members[0].name
result.append(
{
"family": family.family_id,
"head_of_household": head or "",
"church": family.church,
"city": family.city,
"state": family.state,
"member_count": len(members),
}
)
return result
@frappe.whitelist()
def get_directory_html(
church,
members_only=0,
include_sub_churches=0,
show_photos=0,
show_roles=0,
show_membership=1,
show_hoh=1,
show_birthdays=0,
show_anniversaries=0,
show_missionaries=0,
):
"""Generate the full HTML for the church directory, ready to print."""
members_only = frappe.utils.cint(members_only)
include_sub_churches = frappe.utils.cint(include_sub_churches)
show_photos = frappe.utils.cint(show_photos)
show_roles = frappe.utils.cint(show_roles)
show_membership = frappe.utils.cint(show_membership)
show_hoh = frappe.utils.cint(show_hoh)
show_birthdays = frappe.utils.cint(show_birthdays)
show_anniversaries = frappe.utils.cint(show_anniversaries)
show_missionaries = frappe.utils.cint(show_missionaries)
church_doc = frappe.get_doc("Church", church)
church_address = None
if church_doc.address:
church_address = frappe.get_doc("Address", church_doc.address)
churches = get_church_scope(church, include_sub_churches)
church_in = build_in_clause(churches)
families = frappe.db.sql(
f"""
SELECT
f.name AS family_id,
f.family_name,
f.church AS church_name,
f.photo AS family_photo,
COALESCE(a.address_line1, '') AS address_line1,
COALESCE(a.address_line2, '') AS address_line2,
COALESCE(a.city, '') AS city,
COALESCE(a.state, '') AS state,
COALESCE(a.pincode, '') AS pincode
FROM `tabFamily` f
LEFT JOIN `tabAddress` a ON a.name = f.home_address
WHERE f.church IN {church_in}
ORDER BY f.family_name ASC
""",
as_dict=True,
)
member_filter = "AND p.is_member = 1" if members_only else ""
all_members = frappe.db.sql(
f"""
SELECT
p.name AS person_name,
p.full_name,
p.primary_phone,
p.email,
p.membership_status,
p.is_head_of_household,
p.photo,
p.family
FROM `tabPerson` p
WHERE p.church IN {church_in}
AND p.family IS NOT NULL AND p.family != ''
{member_filter}
ORDER BY p.family, p.is_head_of_household DESC, p.last_name, p.first_name
""",
as_dict=True,
)
# Fetch active positions if requested
roles_by_person = {}
if show_roles:
today = frappe_today()
active_positions = frappe.db.sql(
f"""
SELECT pos.parent AS person_name, pos.position
FROM `tabPosition` pos
INNER JOIN `tabPerson` p ON p.name = pos.parent
WHERE pos.parenttype = 'Person'
AND pos.position IS NOT NULL
AND pos.start_date <= %(today)s
AND (pos.end_date IS NULL OR pos.end_date >= %(today)s)
AND p.church IN {church_in}
ORDER BY pos.parent, pos.start_date
""",
{"today": today},
as_dict=True,
)
for row in active_positions:
roles_by_person.setdefault(row.person_name, []).append(row.position)
for m in all_members:
m["positions"] = roles_by_person.get(m.person_name, [])
members_by_family = {}
for m in all_members:
members_by_family.setdefault(m.family, []).append(m)
individuals_raw = frappe.db.sql(
f"""
SELECT
p.name AS person_name,
p.full_name,
p.last_name,
p.primary_phone,
p.email,
p.membership_status,
p.photo,
p.church AS church_name
FROM `tabPerson` p
WHERE p.church IN {church_in}
AND (p.family IS NULL OR p.family = '')
{member_filter}
ORDER BY p.last_name, p.first_name
""",
as_dict=True,
)
for p in individuals_raw:
p["positions"] = roles_by_person.get(p.person_name, [])
p["is_head_of_household"] = 0
# Build merged sorted entry list
all_entries = []
for family in families:
members = members_by_family.get(family.family_id, [])
if members:
all_entries.append(
{
"sort_name": family.family_name,
"display_name": family.family_name + " Family",
"is_individual": False,
"church_name": family.church_name,
"family_photo": family.family_photo,
"address_line1": family.address_line1,
"address_line2": family.address_line2,
"city": family.city,
"state": family.state,
"pincode": family.pincode,
"members": members,
}
)
for person in individuals_raw:
sort_key = (person.get("last_name") or person.get("full_name") or "").strip()
all_entries.append(
{
"sort_name": sort_key,
"display_name": person.full_name,
"is_individual": True,
"church_name": person.church_name,
"family_photo": None,
"address_line1": "",
"address_line2": "",
"city": "",
"state": "",
"pincode": "",
"members": [person],
}
)
all_entries.sort(key=lambda e: (e["sort_name"] or "").upper())
# ── Birthdays ────────────────────────────────────────────────
birthdays = []
if show_birthdays:
raw_birthdays = frappe.db.sql(
f"""
SELECT
p.full_name,
p.birthday,
MONTH(p.birthday) AS birth_month,
DAY(p.birthday) AS birth_day
FROM `tabPerson` p
WHERE p.church IN {church_in}
AND p.birthday IS NOT NULL
{member_filter}
ORDER BY MONTH(p.birthday), DAY(p.birthday), p.last_name, p.first_name
""",
as_dict=True,
)
for row in raw_birthdays:
row["month_name"] = calendar.month_name[int(row.birth_month)]
row["month_day"] = f"{calendar.month_name[int(row.birth_month)]} {int(row.birth_day)}"
birthdays.append(row)
# ── Anniversaries ────────────────────────────────────────────
anniversaries = []
if show_anniversaries:
raw_anniversaries = frappe.db.sql(
f"""
SELECT
p.name AS person_name,
p.spouse AS spouse_name,
p.first_name AS person_first,
p.full_name AS person_full,
s.first_name AS spouse_first,
COALESCE(f.family_name, '') AS family_name,
p.anniversary,
MONTH(p.anniversary) AS ann_month,
DAY(p.anniversary) AS ann_day
FROM `tabPerson` p
LEFT JOIN `tabPerson` s ON s.name = p.spouse
LEFT JOIN `tabFamily` f ON f.name = p.family
WHERE p.church IN {church_in}
AND p.anniversary IS NOT NULL
AND p.is_married = 1
{member_filter}
ORDER BY MONTH(p.anniversary), DAY(p.anniversary), p.last_name, p.first_name
""",
as_dict=True,
)
seen_persons = set()
for row in raw_anniversaries:
if row.person_name in seen_persons:
continue
seen_persons.add(row.person_name)
if row.spouse_name:
seen_persons.add(row.spouse_name)
row["month_name"] = calendar.month_name[int(row.ann_month)]
row["month_day"] = f"{calendar.month_name[int(row.ann_month)]} {int(row.ann_day)}"
if row.spouse_first and row.family_name:
row["display_name"] = f"{row.person_first} & {row.spouse_first} {row.family_name}"
elif row.spouse_first:
row["display_name"] = f"{row.person_full} & {row.spouse_first}"
else:
row["display_name"] = row.person_full
anniversaries.append(row)
# ── Missionaries ─────────────────────────────────────────────
missionaries = []
if show_missionaries:
missionaries = frappe.db.sql(
f"""
SELECT
m.title,
m.agency,
m.country,
m.email,
m.website,
m.photo,
m.sensitive,
m.mission_statement
FROM `tabMissionary` m
WHERE m.church IN {church_in}
ORDER BY m.title
""",
as_dict=True,
)
template_path = os.path.join(os.path.dirname(__file__), "church_directory.html")
with open(template_path) as f:
template = f.read()
context = {
"church": church_doc,
"church_address": church_address,
"all_entries": all_entries,
"show_church_label": bool(include_sub_churches),
"show_photos": show_photos,
"show_roles": show_roles,
"show_membership": show_membership,
"show_hoh": show_hoh,
"birthdays": birthdays,
"anniversaries": anniversaries,
"missionaries": missionaries,
"show_birthdays": show_birthdays,
"show_anniversaries": show_anniversaries,
"show_missionaries": show_missionaries,
"generated_date": frappe.utils.formatdate(frappe.utils.nowdate(), "MMMM yyyy"),
}
return frappe.render_template(template, context)
def get_church_scope(church, include_sub_churches):
"""Return the church itself, or the full subtree if include_sub_churches is set."""
if not include_sub_churches:
return [church]
return frappe.db.sql_list(
"""
SELECT child.name
FROM `tabChurch` child
INNER JOIN `tabChurch` parent
ON child.lft >= parent.lft AND child.rgt <= parent.rgt
WHERE parent.name = %s
ORDER BY child.lft
""",
church,
)
def build_in_clause(values):
"""Return a safely escaped SQL IN clause string, e.g. ('A', 'B')."""
escaped = [frappe.db.escape(v) for v in values]
return "(" + ", ".join(escaped) + ")"

View File

@ -1,11 +1,11 @@
{
"charts": [
{
"chart_name": "Persons Count",
"chart_name": "People",
"label": "Persons"
},
{
"chart_name": "Member Count (New by Month)",
"chart_name": "Members",
"label": "New Members"
}
],
@ -82,12 +82,23 @@
"onboard": 0,
"type": "Link"
},
{
"hidden": 0,
"is_query_report": 0,
"label": "Church Directory",
"link_count": 0,
"link_to": "Church Directory Report",
"link_type": "Report",
"onboard": 0,
"report_ref_doctype": "Family",
"type": "Link"
},
{
"description": "These are some pre-prepared reports. You can always create your own reports by going to `[Document] Report`, filtering the report, then choosing `save as` in the `...` menu.",
"hidden": 0,
"is_query_report": 0,
"label": "Person Reports",
"link_count": 5,
"link_count": 6,
"link_type": "DocType",
"onboard": 0,
"type": "Card Break"
@ -144,9 +155,19 @@
"link_type": "Report",
"onboard": 0,
"type": "Link"
},
{
"hidden": 0,
"is_query_report": 1,
"label": "Church Directory",
"link_count": 0,
"link_to": "Church Directory Report",
"link_type": "Report",
"onboard": 0,
"type": "Link"
}
],
"modified": "2026-03-04 23:13:06.657575",
"modified": "2026-03-07 16:11:26.024550",
"modified_by": "Administrator",
"module": "Church People",
"name": "People",
@ -157,11 +178,11 @@
},
{
"label": "Members",
"number_card_name": "Church Members"
"number_card_name": "Members"
},
{
"label": "Families",
"number_card_name": "Church Families"
"number_card_name": "Families"
}
],
"owner": "Administrator",