Skip to content

Commit

Permalink
Services list for multiple services (#70)
Browse files Browse the repository at this point in the history
* Add ChurchTools /groups/:id/members endpoint

* Include comment in service history endpoint

* Change service history endpoint for multiple services

* Change frontend to use changed backend endpoint

* Add documentation for additional ChurchTools permission

* add multiselect and sort options to service list

* fix lint errors

* Rename interface Services in frontend

* remove unnecessary css class and show all comments from all groups

* Leave multiselect open and make comment non-nullable

* display the sort selection in the same line as the service selection

* show a badge, when user is inactive in a group

* show only one status tag, if all the other tags would be the same status

---------

Co-authored-by: Beniox <37512887+Beniox@users.noreply.github.com>
  • Loading branch information
daniel-lerch and Beniox authored Aug 7, 2024
1 parent 6e83dc5 commit ff6bdbe
Show file tree
Hide file tree
Showing 16 changed files with 331 additions and 88 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ Grant the following permissions to Korga's user:
- Personen & Gruppen > Sicherheitslevel Personendaten (Stufe 1-3) `churchdb:security level person(1,2,3)`
- Personen & Gruppen > Alle Personen des jeweiligen Bereiches sichtbar machen (Alle) `churchdb:view alldata(-1)`
- Personen & Gruppen > Einzelne Gruppen inkl. der enthaltenen Personen sehen (gilt auch für versteckte Gruppen) (Alle) `churchdb:view group(-1)`
- Personen & Gruppen > Gruppenmitgliedschaften aller sichtbaren Personen bearbeiten `churchdb:edit group memberships`
- Events > "Events" sehen `churchservice:view`
- Events > Dienste einzelner Dienstgruppen einsehen (Alle) `churchservice:view servicegroup(-1)`
- Events > Events von einzelnen Kalendern sehen (Alle) `churchservice:view events(-1)`
Expand Down
9 changes: 7 additions & 2 deletions server/ChurchTools/ChurchToolsApi.cs
Original file line number Diff line number Diff line change
Expand Up @@ -92,9 +92,14 @@ public ValueTask<List<Group>> GetGroups(IEnumerable<int> groupStatuses, Cancella
return InternalGetAllPages<Group>("/api/groups", query.ToString(), cancellationToken);
}

public ValueTask<List<GroupMember>> GetGroupMembers(CancellationToken cancellationToken = default)
public ValueTask<List<GroupsMember>> GetGroupMembers(CancellationToken cancellationToken = default)
{
return InternalGetNonPaged<List<GroupMember>>("/api/groups/members", cancellationToken);
return InternalGetNonPaged<List<GroupsMember>>("/api/groups/members", cancellationToken);
}

public ValueTask<List<GroupMember>> GetGroupMembers(int groupId, CancellationToken cancellationToken = default)
{
return InternalGetAllPages<GroupMember>($"/api/groups/{groupId}/members", null, cancellationToken);
}

public ValueTask<List<Person>> GetPeople(CancellationToken cancellationToken = default)
Expand Down
3 changes: 2 additions & 1 deletion server/ChurchTools/IChurchToolsApi.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ public interface IChurchToolsApi : IDisposable
Login? User { get; }
ValueTask<List<Group>> GetGroups(CancellationToken cancellationToken = default);
ValueTask<List<Group>> GetGroups(IEnumerable<int> groupStatuses, CancellationToken cancellationToken = default);
ValueTask<List<GroupMember>> GetGroupMembers(CancellationToken cancellationToken = default);
ValueTask<List<GroupsMember>> GetGroupMembers(CancellationToken cancellationToken = default);
ValueTask<List<GroupMember>> GetGroupMembers(int groupId, CancellationToken cancellationToken = default);
ValueTask<List<Person>> GetPeople(CancellationToken cancellationToken = default);
ValueTask<Person> GetPerson(CancellationToken cancellationToken = default);
ValueTask<Person> GetPerson(int personId, CancellationToken cancellationToken = default);
Expand Down
10 changes: 10 additions & 0 deletions server/ChurchTools/Model/DomainObject.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
using System.Collections.Generic;
using System.Text.Json.Nodes;

namespace ChurchTools.Model;

public record DomainObject(
string Title,
string DomainType,
string DomainIdentifier,
Dictionary<string, JsonValue> DomainAttributes);
28 changes: 11 additions & 17 deletions server/ChurchTools/Model/GroupMember.cs
Original file line number Diff line number Diff line change
@@ -1,19 +1,13 @@
namespace ChurchTools.Model;
using System;

public class GroupMember : IIdentifiable<long>
{
public GroupMember(int personId, int groupId, int groupTypeRoleId, string groupMemberStatus)
{
PersonId = personId;
GroupId = groupId;
GroupTypeRoleId = groupTypeRoleId;
GroupMemberStatus = groupMemberStatus;
}
namespace ChurchTools.Model;

public int PersonId { get; set; }
public int GroupId { get; set; }
public int GroupTypeRoleId { get; set; }
public string GroupMemberStatus { get; set; }

long IIdentifiable<long>.Id => (long)PersonId << 32 | (long)GroupId;
}
public record GroupMember(
int PersonId,
DomainObject Person,
DomainObject Group,
int GroupTypeRoleId,
GroupMemberStatus GroupMemberStatus,
DateOnly MemberStartDate,
DateOnly? MemberEndDate,
string? Comment);
8 changes: 6 additions & 2 deletions server/ChurchTools/Model/GroupMemberStatus.cs
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
namespace ChurchTools.Model;
using System.Text.Json.Serialization;

namespace ChurchTools.Model;

[JsonConverter(typeof(JsonStringEnumConverter))]
public enum GroupMemberStatus
{
Active,
Requested,
ToDelete
Waiting,
To_Delete // Custom JSON values are not supported by System.Text.Json: https://stackoverflow.com/a/59061296
}
19 changes: 19 additions & 0 deletions server/ChurchTools/Model/GroupsMember.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
namespace ChurchTools.Model;

public class GroupsMember : IIdentifiable<long>
{
public GroupsMember(int personId, int groupId, int groupTypeRoleId, string groupMemberStatus)
{
PersonId = personId;
GroupId = groupId;
GroupTypeRoleId = groupTypeRoleId;
GroupMemberStatus = groupMemberStatus;
}

public int PersonId { get; set; }
public int GroupId { get; set; }
public int GroupTypeRoleId { get; set; }
public string GroupMemberStatus { get; set; }

long IIdentifiable<long>.Id => (long)PersonId << 32 | (long)GroupId;
}
9 changes: 7 additions & 2 deletions server/Korga.Tests/ChurchToolsSyncServiceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -246,7 +246,7 @@ private class FakeChurchToolsApi : IChurchToolsApi
public PersonMasterdata PersonMasterdata { get; } = new();
public List<Person> People { get; } = [];
public List<Group> Groups { get; } = [];
public List<GroupMember> GroupMembers { get; } = [];
public List<GroupsMember> GroupMembers { get; } = [];

public void Dispose() { }

Expand All @@ -260,11 +260,16 @@ public ValueTask<List<Group>> GetGroups(IEnumerable<int> groupStatuses, Cancella
return ValueTask.FromResult(Groups);
}

public ValueTask<List<GroupMember>> GetGroupMembers(CancellationToken cancellationToken = default)
public ValueTask<List<GroupsMember>> GetGroupMembers(CancellationToken cancellationToken = default)
{
return ValueTask.FromResult(GroupMembers);
}

public ValueTask<List<GroupMember>> GetGroupMembers(int groupId, CancellationToken cancellationToken = default)
{
throw new NotImplementedException();
}

public ValueTask<List<Person>> GetPeople(CancellationToken cancellationToken = default)
{
return ValueTask.FromResult(People);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,9 @@ private async ValueTask CheckSyncPermissions(GlobalPermissions permissions, IChu

private void CheckServiceHistoryPermissions(GlobalPermissions permissions)
{
if (!permissions.ChurchDb.EditGroupMemberships)
// https://forum.church.tools/topic/10368/berechtigungen-f%C3%BCr-groups-groupid-members-endpunkt
logger.LogWarning("ChurchTools user does not have permission 'churchdb:edit group memberships'. Event history will not show comments.");
if (!permissions.ChurchService.View)
logger.LogWarning("ChurchTools user does not have permission 'churchservice:view'. Event history will not work.");
if (permissions.ChurchService.ViewServiceGroup.Count == 0)
Expand Down
5 changes: 3 additions & 2 deletions server/Korga/ChurchTools/ChurchToolsSyncService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ await database.DepartmentMembers.OrderBy(x => x.PersonId).ThenBy(x => x.Departme

private async ValueTask SynchronizeGroupMembers(CancellationToken cancellationToken)
{
await Synchronize<GroupMember, DbGroupMember, long>(
await Synchronize<GroupsMember, DbGroupMember, long>(
await churchTools.GetGroupMembers(cancellationToken),
database.GroupMembers,
await database.GroupMembers.OrderBy(x => x.PersonId).ThenBy(x => x.GroupId).ToListAsync(cancellationToken),
Expand Down Expand Up @@ -187,7 +187,8 @@ private static GroupMemberStatus ParseGroupMemberStatus(string groupMemberStatus
{
"active" => GroupMemberStatus.Active,
"requested" => GroupMemberStatus.Requested,
"to_delete" => GroupMemberStatus.ToDelete,
"waiting" => GroupMemberStatus.Waiting,
"to_delete" => GroupMemberStatus.To_Delete,
_ => throw new ArgumentException($"Unknown GroupMemberStatus {groupMemberStatus}")
};
}
Expand Down
91 changes: 60 additions & 31 deletions server/Korga/Controllers/ServiceController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using System;
using System.Collections.Generic;
using System.Linq;
Expand All @@ -16,12 +15,10 @@ namespace Korga.Controllers;
[ApiController]
public class ServiceController : ControllerBase
{
private readonly DatabaseContext database;
private readonly IChurchToolsApi churchTools;

public ServiceController(DatabaseContext database, IChurchToolsApi churchTools)
public ServiceController(IChurchToolsApi churchTools)
{
this.database = database;
this.churchTools = churchTools;
}

Expand All @@ -43,30 +40,16 @@ public async Task<IActionResult> GetServices()
}));
}

[HttpGet("~/api/services/{id}/history")]
[HttpGet("~/api/services/history")]
[ProducesResponseType(typeof(ServiceHistoryResponse[]), StatusCodes.Status200OK)]
public async Task<IActionResult> GetServiceHistory(int id, [FromQuery] DateOnly? from, [FromQuery] DateOnly? to)
public async Task<IActionResult> GetServiceHistory([FromQuery] HashSet<int> serviceId, [FromQuery] DateOnly? from, [FromQuery] DateOnly? to)
{
Service service = await churchTools.GetService(id);

if (service.GroupIds == null) return new JsonResult(Array.Empty<ServiceHistoryResponse>());

List<int> groupIds = service.GroupIds.Split(',', StringSplitOptions.RemoveEmptyEntries)
.Select(int.Parse)
.ToList();

var people = await
(from member in database.GroupMembers.Where(member => groupIds.Contains(member.GroupId))
join person in database.People on member.PersonId equals person.Id
select new ServiceHistoryResponse
{
PersonId = person.Id,
FirstName = person.FirstName,
LastName = person.LastName,
GroupMemberStatus = member.GroupMemberStatus,
})
.Distinct()
.ToDictionaryAsync(x => x.PersonId);
var groupIds = await GetServiceGroups(serviceId);
var groupMembers = await GetGroupMembers(groupIds);

Dictionary<int, ServiceHistoryResponse> people = groupMembers.ToDictionary(x => x.PersonId);

if (people.Count == 0) return new JsonResult(Array.Empty<ServiceHistoryResponse>());

from ??= DateOnly.FromDateTime(DateTime.UtcNow.AddMonths(-12));
to ??= DateOnly.FromDateTime(DateTime.UtcNow.AddMonths(3));
Expand All @@ -76,18 +59,64 @@ join person in database.People on member.PersonId equals person.Id
{
foreach (Event.Service eventService in @event.EventServices)
{
if (eventService.ServiceId == id
if (serviceId.Contains(eventService.ServiceId)
&& eventService.PersonId.HasValue
&& eventService.Agreed
&& people.TryGetValue(eventService.PersonId.Value, out var person))
{
person.ServiceDates.Add(DateOnly.FromDateTime(@event.StartDate));
person.ServiceDates.Add(new()
{
ServiceId = eventService.ServiceId,
Date = DateOnly.FromDateTime(@event.StartDate)
});
}
}
}

List<ServiceHistoryResponse> peopleList = people.Values.ToList();
peopleList.Sort((a, b) => a.ServiceDates.LastOrDefault().CompareTo(b.ServiceDates.LastOrDefault()));
return new JsonResult(peopleList);
return new JsonResult(people.Values.ToList());
}

private async ValueTask<IEnumerable<int>> GetServiceGroups(IEnumerable<int> serviceIds)
{
Service[] services = await Task.WhenAll(serviceIds.Select(id => churchTools.GetService(id).AsTask()));

HashSet<int> groupIds = [];
foreach (Service service in services)
{
if (service.GroupIds == null) continue;
groupIds.UnionWith(service.GroupIds.Split(',', StringSplitOptions.RemoveEmptyEntries).Select(int.Parse));
}
return groupIds;
}

private async ValueTask<IEnumerable<ServiceHistoryResponse>> GetGroupMembers(IEnumerable<int> groupIds)
{
List<GroupMember>[] groupsMembers = await Task.WhenAll(groupIds
.Select(groupId => churchTools.GetGroupMembers(groupId).AsTask()));

return groupsMembers.SelectMany(x => x).GroupBy(
x => x.PersonId,
x => new
{
FirstName = x.Person.DomainAttributes["firstName"].GetValue<string>(),
LastName = x.Person.DomainAttributes["lastName"].GetValue<string>(),
GroupId = int.Parse(x.Group.DomainIdentifier),
GroupName = x.Group.Title,
x.GroupMemberStatus,
x.Comment
},
(key, x) => new ServiceHistoryResponse
{
PersonId = key,
FirstName = x.First().FirstName,
LastName = x.First().LastName,
Groups = x.Select(y => new ServiceHistoryResponse.GroupInfo()
{
GroupId = y.GroupId,
GroupName = y.GroupName,
GroupMemberStatus = y.GroupMemberStatus,
Comment = y.Comment ?? string.Empty,
}).ToList()
});
}
}
20 changes: 17 additions & 3 deletions server/Korga/Models/Json/ServiceHistoryResponse.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,23 @@ public class ServiceHistoryResponse
public required int PersonId { get; init; }
public required string FirstName { get; init; }
public required string LastName { get; init; }
public required List<GroupInfo> Groups { get; init; }

[JsonConverter(typeof(JsonStringEnumConverter))]
public required GroupMemberStatus GroupMemberStatus { get; init; }
public List<ServiceDate> ServiceDates { get; } = [];

public List<DateOnly> ServiceDates { get; } = [];
public readonly struct GroupInfo
{
public required int GroupId { get; init; }
public required string GroupName { get; init; }

[JsonConverter(typeof(JsonStringEnumConverter))]
public required GroupMemberStatus GroupMemberStatus { get; init; }
public required string Comment { get; init; }
}

public readonly struct ServiceDate
{
public required int ServiceId { get; init; }
public required DateOnly Date { get; init; }
}
}
10 changes: 10 additions & 0 deletions webapp/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions webapp/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
"bootstrap": "~5.3.3",
"core-js": "^3.38.0",
"vue": "^3.4.35",
"vue-multiselect": "^3.0.0",
"vue-router": "^4.4.2"
},
"devDependencies": {
Expand Down
24 changes: 18 additions & 6 deletions webapp/src/services/service.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import client from "./client"

export interface Services {
export interface Service {
id: number
name: string
serviceGroup: string | null
Expand All @@ -10,14 +10,26 @@ export interface ServiceHistory {
personId: number
firstName: string
lastName: string
groupMemberStatus: "Active" | "Requested" | "ToDelete"
serviceDates: string[]
groups: {
groupId: number
groupName: string
groupMemberStatus: "Active" | "Requested" | "To_Delete"
comment: string
}[]
serviceDates: ServiceDate[]
}

export function getServices(): Promise<Services[]> {
export interface ServiceDate {
serviceId: number
date: string
}

export function getServices(): Promise<Service[]> {
return client.get("/api/services")
}

export function getServiceHistory(id: number): Promise<ServiceHistory[]> {
return client.get(`/api/services/${id}/history`)
export function getServiceHistory(ids: number[]): Promise<ServiceHistory[]> {
return client.get(
`/api/services/history/?${ids.map((id) => `serviceId=${id}`).join("&")}`
)
}
Loading

0 comments on commit ff6bdbe

Please sign in to comment.