diff --git a/framework/src/domain/invitationcontroller.cpp b/framework/src/domain/invitationcontroller.cpp index c8d59636..a8ad193d 100644 --- a/framework/src/domain/invitationcontroller.cpp +++ b/framework/src/domain/invitationcontroller.cpp @@ -1,335 +1,336 @@ /* * Copyright (C) 2017 Michael Bohlender, * Copyright (C) 2018 Christian Mollekopf, * * This program is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation; either version 2 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License along * with this program; if not, write to the Free Software Foundation, Inc., * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ #include "invitationcontroller.h" #include #include #include #include #include #include #include #include "mailtemplates.h" #include "sinkutils.h" using namespace Sink::ApplicationDomain; InvitationController::InvitationController() : EventController(), action_accept{new Kube::ControllerAction{this, &InvitationController::accept}}, action_decline{new Kube::ControllerAction{this, &InvitationController::decline}} { } static QString assembleEmailAddress(const QString &name, const QString &email) { KMime::Types::Mailbox mb; mb.setName(name); mb.setAddress(email.toUtf8()); return mb.prettyAddress(); } void InvitationController::handleReply(KCalCore::Event::Ptr icalEvent) { using namespace Sink; using namespace Sink::ApplicationDomain; setMethod(InvitationMethod::Reply); auto attendees = icalEvent->attendees(); if (!attendees.isEmpty()) { auto attendee = attendees.first(); if (attendee.status() == KCalCore::Attendee::Declined) { setState(InvitationState::Declined); } else if (attendee.status() == KCalCore::Attendee::Accepted) { setState(InvitationState::Accepted); } else { setState(InvitationState::Unknown); } setName(assembleEmailAddress(attendee.name(), attendee.email())); } populateFromEvent(*icalEvent); setStart(icalEvent->dtStart()); setEnd(icalEvent->dtEnd()); setUid(icalEvent->uid().toUtf8()); } void InvitationController::handleRequest(KCalCore::Event::Ptr icalEvent) { using namespace Sink; using namespace Sink::ApplicationDomain; setMethod(InvitationMethod::Request); Query query; query.request(); query.request(); query.filter(icalEvent->uid().toUtf8()); Store::fetchAll(query).then([this, icalEvent](const QList &events) { - if (!events.isEmpty()) { - //Find the matching occurrence in case of exceptions - const auto [event, localEvent] = [&] { - for (const auto &e : events) { - auto ical = KCalCore::ICalFormat().readIncidence(e->getIcal()).dynamicCast(); - if (ical && ical->instanceIdentifier() == icalEvent->instanceIdentifier()) { - return std::pair(*e, ical); - } + //Find the matching occurrence in case of exceptions + const auto [event, localEvent] = [&] { + for (const auto &e : events) { + auto ical = KCalCore::ICalFormat().readIncidence(e->getIcal()).dynamicCast(); + if (ical && ical->instanceIdentifier() == icalEvent->instanceIdentifier()) { + return std::pair(*e, ical); } - return std::pair{}; - }(); - if(!localEvent) { - SinkWarning() << "Invalid ICal to process, ignoring..."; - return KAsync::null(); } - + return std::pair{}; + }(); + if (localEvent) { mExistingEvent = event; if (icalEvent->revision() > localEvent->revision()) { setEventState(InvitationController::Update); //The invitation is more recent, this is an update to an existing event populateFromEvent(*icalEvent); if (icalEvent->recurrenceId().isValid()) { setRecurrenceId(icalEvent->recurrenceId()); } setStart(icalEvent->dtStart()); setEnd(icalEvent->dtEnd()); setUid(icalEvent->uid().toUtf8()); } else { setEventState(InvitationController::Existing); //Our local copy is more recent (we probably already dealt with the invitation) populateFromEvent(*localEvent); setStart(localEvent->dtStart()); setEnd(localEvent->dtEnd()); setUid(localEvent->uid().toUtf8()); } } else { - setEventState(InvitationController::New); + mExistingEvent = {}; + if (icalEvent->recurrenceId().isValid()) { + setRecurrenceId(icalEvent->recurrenceId()); + setEventState(InvitationController::Update); + } else { + setEventState(InvitationController::New); + } //We don't even have a local copy, this is a new event populateFromEvent(*icalEvent); setStart(icalEvent->dtStart()); setEnd(icalEvent->dtEnd()); setUid(icalEvent->uid().toUtf8()); } Query query; query.request() .request() .request(); auto job = Store::fetchAll(query) .guard(this) .then([this] (const QList &list) { if (list.isEmpty()) { SinkWarning() << "Failed to find an identity"; } for (const auto &identity : list) { const auto id = attendeesController()->findByProperty("email", identity->getAddress()); if (!id.isEmpty()) { const auto status = attendeesController()->value(id, "status").value(); if (status == EventController::Accepted) { setState(InvitationController::Accepted); } else if (status == EventController::Declined) { setState(InvitationController::Declined); } else { setState(InvitationController::Unknown); } return; } else { SinkLog() << "No attendee found for " << identity->getAddress(); } } SinkWarning() << "Failed to find matching identity in list of attendees."; setState(InvitationState::NoMatch); }); return job; }).exec(); } void InvitationController::loadICal(const QString &ical) { using namespace Sink; using namespace Sink::ApplicationDomain; KCalCore::Calendar::Ptr calendar(new KCalCore::MemoryCalendar{QTimeZone::systemTimeZone()}); auto msg = KCalCore::ICalFormat{}.parseScheduleMessage(calendar, ical.toUtf8()); if (!msg) { SinkWarning() << "Invalid schedule message to process, ignoring..."; return; } auto icalEvent = msg->event().dynamicCast(); if(!icalEvent) { SinkWarning() << "Invalid ICal to process, ignoring..."; return; } mLoadedIcalEvent = icalEvent; switch (msg->method()) { case KCalCore::iTIPRequest: handleRequest(icalEvent); break; case KCalCore::iTIPReply: handleReply(icalEvent); break; default: SinkWarning() << "Invalid method " << msg->method(); } } static void sendIMipReply(const QByteArray &accountId, const QString &from, const QString &fromName, KCalCore::Event::Ptr event, KCalCore::Attendee::PartStat status) { const auto organizerEmail = event->organizer().fullName(); if (organizerEmail.isEmpty()) { SinkWarning() << "Failed to find the organizer to send the reply to " << organizerEmail; return; } auto reply = KCalCore::Event::Ptr::create(*event); reply->clearAttendees(); reply->addAttendee(KCalCore::Attendee(fromName, from, false, status)); QString body; if (status == KCalCore::Attendee::Accepted) { body.append(QObject::tr("%1 has accepted the invitation to the following event").arg(fromName)); } else { body.append(QObject::tr("%1 has declined the invitation to the following event").arg(fromName)); } body.append("\n\n"); body.append(EventController::eventToBody(*reply)); QString subject; if (status == KCalCore::Attendee::Accepted) { subject = QObject::tr("\"%1\" has been accepted by %2").arg(event->summary()).arg(fromName); } else { subject = QObject::tr("\"%1\" has been declined by %2").arg(event->summary()).arg(fromName); } const auto msg = MailTemplates::createIMipMessage( from, {{organizerEmail}, {}, {}}, subject, body, KCalCore::ICalFormat{}.createScheduleMessage(reply, KCalCore::iTIPReply) ); SinkTrace() << "Msg " << msg->encodedContent(); SinkUtils::sendMail(msg->encodedContent(true), accountId) .then([&] (const KAsync::Error &error) { if (error) { SinkWarning() << "Failed to send message " << error; } }).exec(); } void InvitationController::storeEvent(InvitationState status) { using namespace Sink; using namespace Sink::ApplicationDomain; Query query; query.request() .request() .request(); auto job = Store::fetchAll(query) .guard(this) .then([this, status] (const QList &list) { if (list.isEmpty()) { SinkWarning() << "Failed to find an identity"; } QString fromAddress; QString fromName; QByteArray accountId; bool foundMatch = false; for (const auto &identity : list) { const auto id = attendeesController()->findByProperty("email", identity->getAddress()); if (!id.isEmpty()) { auto participantStatus = status == InvitationController::Accepted ? EventController::Accepted : EventController::Declined; attendeesController()->setValue(id, "status", participantStatus); fromAddress = identity->getAddress(); fromName = identity->getName(); accountId = identity->getAccount(); foundMatch = true; } else { SinkLog() << "No identity found for " << identity->getAddress(); } } if (!foundMatch) { SinkWarning() << "Failed to find a matching identity."; return KAsync::error("Failed to find a matching identity"); } auto calcoreEvent = mLoadedIcalEvent; calcoreEvent->setUid(getUid()); saveToEvent(*calcoreEvent); sendIMipReply(accountId, fromAddress, fromName, calcoreEvent, status == InvitationController::Accepted ? KCalCore::Attendee::Accepted : KCalCore::Attendee::Declined); - if (getEventState() == InvitationController::New || getRecurrenceId().isValid()) { + if (mExistingEvent.identifier().isEmpty()) { const auto calendar = getCalendar(); if (!calendar) { SinkWarning() << "No calendar selected"; return KAsync::error("No calendar selected"); } Event event(calendar->resourceInstanceIdentifier()); event.setIcal(KCalCore::ICalFormat().toICalString(calcoreEvent).toUtf8()); event.setCalendar(*calendar); return Store::create(event) .then([=] (const KAsync::Error &error) { if (error) { SinkWarning() << "Failed to save the event: " << error; } setState(status); emit done(); }); } else { Event event(mExistingEvent); event.setIcal(KCalCore::ICalFormat().toICalString(calcoreEvent).toUtf8()); return Store::modify(event) .then([=] (const KAsync::Error &error) { if (error) { SinkWarning() << "Failed to update the event: " << error; } setState(status); setEventState(InvitationController::Existing); emit done(); }); } }); run(job); } void InvitationController::accept() { storeEvent(InvitationState::Accepted); } void InvitationController::decline() { storeEvent(InvitationState::Declined); } diff --git a/framework/src/tests/invitationcontrollertest.cpp b/framework/src/tests/invitationcontrollertest.cpp index 4e8e1cc1..959d2bfa 100644 --- a/framework/src/tests/invitationcontrollertest.cpp +++ b/framework/src/tests/invitationcontrollertest.cpp @@ -1,158 +1,309 @@ #include #include #include #include #include #include #include #include #include #include #include #include "invitationcontroller.h" using namespace Sink::ApplicationDomain; class InvitationControllerTest : public QObject { Q_OBJECT QByteArray resourceId; QByteArray mailtransportResourceId; - QString createInvitation(const QByteArray &uid, const QString &summary, int revision) + QString createInvitation(const QByteArray &uid, const QString &summary, int revision, QDateTime dtStart = QDateTime::currentDateTime(), bool recurring = false, QDateTime recurrenceId = {}) { auto calcoreEvent = QSharedPointer::create(); calcoreEvent->setUid(uid); calcoreEvent->setSummary(summary); calcoreEvent->setDescription("description"); calcoreEvent->setLocation("location"); - calcoreEvent->setDtStart(QDateTime::currentDateTime()); + calcoreEvent->setDtStart(dtStart); calcoreEvent->setOrganizer("organizer@test.com"); calcoreEvent->addAttendee(KCalCore::Attendee("John Doe", "attendee1@test.com", true, KCalCore::Attendee::NeedsAction)); calcoreEvent->setRevision(revision); + if (recurring) { + calcoreEvent->recurrence()->setDaily(1); + } + if (recurrenceId.isValid()) { + calcoreEvent->setRecurrenceId(recurrenceId); + } + return KCalCore::ICalFormat{}.createScheduleMessage(calcoreEvent, KCalCore::iTIPRequest); } private slots: void initTestCase() { Sink::Test::initTest(); auto account = ApplicationDomainType::createEntity(); Sink::Store::create(account).exec().waitForFinished(); auto identity = ApplicationDomainType::createEntity(); identity.setAccount(account); identity.setAddress("attendee1@test.com"); identity.setName("John Doe"); Sink::Store::create(identity).exec().waitForFinished(); auto resource = DummyResource::create(account.identifier()); Sink::Store::create(resource).exec().waitForFinished(); resourceId = resource.identifier(); auto mailtransport = MailtransportResource::create(account.identifier()); Sink::Store::create(mailtransport).exec().waitForFinished(); mailtransportResourceId = mailtransport.identifier(); } void testAccept() { auto calendar = ApplicationDomainType::createEntity(resourceId); Sink::Store::create(calendar).exec().waitForFinished(); const QByteArray uid{"uid1"}; const auto ical = createInvitation(uid, "summary", 0); { InvitationController controller; controller.loadICal(ical); controller.setCalendar(ApplicationDomainType::Ptr::create(calendar)); QTRY_COMPARE(controller.getState(), InvitationController::Unknown); QTRY_COMPARE(controller.getEventState(), InvitationController::New); controller.acceptAction()->execute(); QTRY_COMPARE(controller.getState(), InvitationController::Accepted); //Ensure the event is stored QTRY_COMPARE(Sink::Store::read(Sink::Query{}.filter(calendar)).size(), 1); auto list = Sink::Store::read(Sink::Query{}.filter(calendar)); QCOMPARE(list.size(), 1); auto event = KCalCore::ICalFormat().readIncidence(list.first().getIcal()).dynamicCast(); QVERIFY(event); QCOMPARE(event->uid().toUtf8(), uid); QCOMPARE(event->organizer().fullName(), QLatin1String{"organizer@test.com"}); const auto attendee = event->attendeeByMail("attendee1@test.com"); QCOMPARE(attendee.status(), KCalCore::Attendee::Accepted); //Ensure the mail is sent to the organizer QTRY_COMPARE(Sink::Store::read(Sink::Query{}.resourceFilter(mailtransportResourceId)).size(), 1); auto mail = Sink::Store::read(Sink::Query{}.resourceFilter(mailtransportResourceId)).first(); auto msg = KMime::Message::Ptr(new KMime::Message); msg->setContent(mail.getMimeMessage()); msg->parse(); QCOMPARE(msg->to()->asUnicodeString(), QLatin1String{"organizer@test.com"}); QCOMPARE(msg->from()->asUnicodeString(), QLatin1String{"attendee1@test.com"}); } //Reload the event { InvitationController controller; controller.loadICal(ical); QTRY_COMPARE(controller.getState(), InvitationController::Accepted); QTRY_COMPARE(controller.getUid(), uid); } const auto updatedIcal = createInvitation(uid, "summary2", 1); //Load an update and accept it { InvitationController controller; controller.loadICal(updatedIcal); QTRY_COMPARE(controller.getEventState(), InvitationController::Update); QTRY_COMPARE(controller.getUid(), uid); //Accept the update controller.acceptAction()->execute(); QTRY_COMPARE(controller.getState(), InvitationController::Accepted); //Ensure the event is stored QTRY_COMPARE(Sink::Store::read(Sink::Query{}.filter(calendar)).size(), 1); auto list = Sink::Store::read(Sink::Query{}.filter(calendar)); QCOMPARE(list.size(), 1); auto event = KCalCore::ICalFormat().readIncidence(list.first().getIcal()).dynamicCast(); QVERIFY(event); QCOMPARE(event->uid().toUtf8(), uid); QCOMPARE(event->summary(), QLatin1String{"summary2"}); } //Reload the event { InvitationController controller; controller.loadICal(updatedIcal); QTRY_COMPARE(controller.getState(), InvitationController::Accepted); QTRY_COMPARE(controller.getUid(), uid); QCOMPARE(controller.getEventState(), InvitationController::Existing); } } - //TODO test accepting an exception on top of an existing recurring event + void testAcceptRecurrenceException() + { + auto calendar = ApplicationDomainType::createEntity(resourceId); + Sink::Store::create(calendar).exec().waitForFinished(); + + const QByteArray uid{"uid2"}; + auto dtstart = QDateTime{{2020, 1, 1}, {14, 0, 0}, Qt::UTC}; + const auto ical = createInvitation(uid, "summary", 0, dtstart, true); + + { + InvitationController controller; + controller.loadICal(ical); + + controller.setCalendar(ApplicationDomainType::Ptr::create(calendar)); + + QTRY_COMPARE(controller.getState(), InvitationController::Unknown); + QTRY_COMPARE(controller.getEventState(), InvitationController::New); + + controller.acceptAction()->execute(); + QTRY_COMPARE(controller.getState(), InvitationController::Accepted); + + //Ensure the event is stored + QTRY_COMPARE(Sink::Store::read(Sink::Query{}.filter(calendar)).size(), 1); + + auto list = Sink::Store::read(Sink::Query{}.filter(calendar)); + QCOMPARE(list.size(), 1); + + auto event = KCalCore::ICalFormat().readIncidence(list.first().getIcal()).dynamicCast(); + QVERIFY(event); + QCOMPARE(event->uid().toUtf8(), uid); + QCOMPARE(event->organizer().fullName(), QLatin1String{"organizer@test.com"}); + } + + //Reload the event + { + InvitationController controller; + controller.loadICal(ical); + QTRY_COMPARE(controller.getState(), InvitationController::Accepted); + QTRY_COMPARE(controller.getUid(), uid); + QVERIFY(!controller.getRecurrenceId().isValid()); + } + + //Load an exception and accept it + { + InvitationController controller; + //TODO I suppose the revision of the exception can also be 0? + controller.loadICal(createInvitation(uid, "exceptionSummary", 1, dtstart.addSecs(3600), false, dtstart)); + controller.setCalendar(ApplicationDomainType::Ptr::create(calendar)); + QTRY_COMPARE(controller.getEventState(), InvitationController::Update); + QTRY_COMPARE(controller.getUid(), uid); + QTRY_COMPARE(controller.getState(), InvitationController::Unknown); + QTRY_COMPARE(controller.getRecurrenceId(), dtstart); + + //Accept the update + controller.acceptAction()->execute(); + + QTRY_COMPARE(controller.getState(), InvitationController::Accepted); + + //Ensure the event is stored + //FIXME, or just flush? + QTRY_COMPARE(Sink::Store::read(Sink::Query{}.filter(calendar)).size(), 2); + + auto list = Sink::Store::read(Sink::Query{}.filter(calendar)); + QCOMPARE(list.size(), 2); + + for (const auto &entry : list) { + auto event = KCalCore::ICalFormat().readIncidence(entry.getIcal()).dynamicCast(); + QVERIFY(event); + QCOMPARE(event->uid().toUtf8(), uid); + if (event->recurrenceId().isValid()) { + QCOMPARE(event->summary(), QLatin1String{"exceptionSummary"}); + } else { + QCOMPARE(event->summary(), QLatin1String{"summary"}); + } + } + } + + //Update the exception and accept it + { + InvitationController controller; + controller.loadICal(createInvitation(uid, "exceptionSummary2", 3, dtstart.addSecs(3600), false, dtstart)); + controller.setCalendar(ApplicationDomainType::Ptr::create(calendar)); + QTRY_COMPARE(controller.getEventState(), InvitationController::Update); + QTRY_COMPARE(controller.getUid(), uid); + QTRY_COMPARE(controller.getState(), InvitationController::Unknown); + QTRY_COMPARE(controller.getRecurrenceId(), dtstart); + + //Accept the update + controller.acceptAction()->execute(); + + QTRY_COMPARE(controller.getState(), InvitationController::Accepted); + + //Ensure the event is stored + //FIXME, or just flush? + QTRY_COMPARE(Sink::Store::read(Sink::Query{}.filter(calendar)).size(), 2); + + auto list = Sink::Store::read(Sink::Query{}.filter(calendar)); + QCOMPARE(list.size(), 2); + + for (const auto &entry : list) { + auto event = KCalCore::ICalFormat().readIncidence(entry.getIcal()).dynamicCast(); + QVERIFY(event); + QCOMPARE(event->uid().toUtf8(), uid); + if (event->recurrenceId().isValid()) { + QCOMPARE(event->summary(), QLatin1String{"exceptionSummary2"}); + } else { + QCOMPARE(event->summary(), QLatin1String{"summary"}); + } + } + } + + //Update the main event and accept it + { + InvitationController controller; + controller.loadICal(createInvitation(uid, "summary2", 4, dtstart, true)); + controller.setCalendar(ApplicationDomainType::Ptr::create(calendar)); + QTRY_COMPARE(controller.getEventState(), InvitationController::Update); + QTRY_COMPARE(controller.getUid(), uid); + QTRY_COMPARE(controller.getState(), InvitationController::Unknown); + QVERIFY(!controller.getRecurrenceId().isValid()); + + //Accept the update + controller.acceptAction()->execute(); + + QTRY_COMPARE(controller.getState(), InvitationController::Accepted); + + //Ensure the event is stored + //FIXME, or just flush? + QTRY_COMPARE(Sink::Store::read(Sink::Query{}.filter(calendar)).size(), 2); + + auto list = Sink::Store::read(Sink::Query{}.filter(calendar)); + QCOMPARE(list.size(), 2); + + for (const auto &entry : list) { + auto event = KCalCore::ICalFormat().readIncidence(entry.getIcal()).dynamicCast(); + QVERIFY(event); + QCOMPARE(event->uid().toUtf8(), uid); + if (event->recurrenceId().isValid()) { + QCOMPARE(event->summary(), QLatin1String{"exceptionSummary2"}); + } else { + QCOMPARE(event->summary(), QLatin1String{"summary2"}); + } + } + } + } }; QTEST_MAIN(InvitationControllerTest) #include "invitationcontrollertest.moc"