From a8d46257191e2183b40ba09f9271383e801ceb74 Mon Sep 17 00:00:00 2001 From: "marius.klein" Date: Thu, 24 Jul 2025 22:55:39 +0200 Subject: [PATCH 01/25] feat(finance/tests): tests for new rules (#155) Also makes some checks safe. Reviewed-on: https://git.jdav-hd.merten.dev/digitales/kompass/pulls/155 Reviewed-by: Christian Merten Co-authored-by: marius.klein Co-committed-by: marius.klein --- jdav_web/finance/models.py | 5 ++ jdav_web/finance/tests.py | 113 ++++++++++++++++++++++++++++++++++++- jdav_web/members/models.py | 13 ++++- 3 files changed, 127 insertions(+), 4 deletions(-) diff --git a/jdav_web/finance/models.py b/jdav_web/finance/models.py index c09cf22..b9c8a7a 100644 --- a/jdav_web/finance/models.py +++ b/jdav_web/finance/models.py @@ -405,6 +405,8 @@ class Statement(CommonModel): @property def org_fee_payant(self): + if self.total_org_fee == 0: + return None return self.subsidy_to if self.subsidy_to else self.allowance_to.all()[0] @property @@ -466,8 +468,11 @@ class Statement(CommonModel): return cvt_to_decimal( min( + # if total costs are more than the max amount of the LJP contribution, we pay the max amount, reduced by taxes (1-settings.LJP_TAX) * settings.LJP_CONTRIBUTION_PER_DAY * self.excursion.ljp_participant_count * self.excursion.ljp_duration, + # if the total costs are less than the max amount, we pay up to 90% of the total costs, reduced by taxes (1-settings.LJP_TAX) * 0.9 * (float(self.total_bills_not_covered) + float(self.total_staff) ), + # we never pay more than the maximum costs of the trip float(self.total_bills_not_covered) ) ) diff --git a/jdav_web/finance/tests.py b/jdav_web/finance/tests.py index 9e04c97..949b124 100644 --- a/jdav_web/finance/tests.py +++ b/jdav_web/finance/tests.py @@ -5,8 +5,9 @@ from django.conf import settings from .models import Statement, StatementUnSubmitted, StatementSubmitted, Bill, Ledger, Transaction,\ StatementUnSubmittedManager, StatementSubmittedManager, StatementConfirmedManager,\ StatementConfirmed, TransactionIssue, StatementManager -from members.models import Member, Group, Freizeit, GEMEINSCHAFTS_TOUR, MUSKELKRAFT_ANREISE, NewMemberOnList,\ +from members.models import Member, Group, Freizeit, LJPProposal, Intervention, GEMEINSCHAFTS_TOUR, MUSKELKRAFT_ANREISE, NewMemberOnList,\ FAHRGEMEINSCHAFT_ANREISE, MALE, FEMALE, DIVERSE +from dateutil.relativedelta import relativedelta # Create your tests here. class StatementTestCase(TestCase): @@ -66,6 +67,116 @@ class StatementTestCase(TestCase): email=settings.TEST_MAIL, gender=DIVERSE) mol = NewMemberOnList.objects.create(member=m, memberlist=ex) ex.membersonlist.add(mol) + + base = timezone.now() + ex = Freizeit.objects.create(name='Wild trip with old people', kilometers_traveled=self.kilometers_traveled, + tour_type=GEMEINSCHAFTS_TOUR, + tour_approach=MUSKELKRAFT_ANREISE, + difficulty=2, date=timezone.datetime(2024, 1, 2, 8, 0, 0, tzinfo=base.tzinfo), end=timezone.datetime(2024, 1, 5, 17, 0, 0, tzinfo=base.tzinfo) ) + + settings.EXCURSION_ORG_FEE = 20 + settings.LJP_TAX = 0.2 + settings.LJP_CONTRIBUTION_PER_DAY = 20 + + self.st5 = Statement.objects.create(night_cost=self.night_cost, excursion=ex) + + for i in range(9): + m = Member.objects.create(prename='Peter {}'.format(i), lastname='Walter', birth_date=timezone.now().date() - relativedelta(years=i+21), + email=settings.TEST_MAIL, gender=DIVERSE) + mol = NewMemberOnList.objects.create(member=m, memberlist=ex) + ex.membersonlist.add(mol) + + ljpproposal = LJPProposal.objects.create( + title='Test proposal', + category=LJPProposal.LJP_STAFF_TRAINING, + goal=LJPProposal.LJP_ENVIRONMENT, + goal_strategy='my strategy', + not_bw_reason=LJPProposal.NOT_BW_ROOMS, + excursion=self.st5.excursion) + + for i in range(3): + int = Intervention.objects.create( + date_start=timezone.datetime(2024, 1, 2+i, 12, 0, 0, tzinfo=base.tzinfo), + duration = 2+i, + activity = 'hi', + ljp_proposal=ljpproposal + ) + + self.b1 = Bill.objects.create( + statement=self.st5, + short_description='covered bill', + explanation='hi', + amount='300', + paid_by=self.fritz, + costs_covered=True, + refunded=False + ) + + self.b2 = Bill.objects.create( + statement=self.st5, + short_description='non-covered bill', + explanation='hi', + amount='900', + paid_by=self.fritz, + costs_covered=False, + refunded=False + ) + + def test_org_fee(self): + # org fee should be collected if participants are older than 26 + self.assertEqual(self.st5.excursion.old_participant_count, 3, 'Calculation of number of old people in excursion is incorrect.') + + total_org = 4 * 3 * 20 # 4 days, 3 old people, 20€ per day + + self.assertEqual(self.st5.total_org_fee_theoretical, total_org, 'Theoretical org_fee should equal to amount per day per person * n_persons * n_days if there are old people.') + self.assertEqual(self.st5.total_org_fee, 0, 'Paid org fee should be 0 if no allowance and subsidies are paid if there are old people.') + + self.assertIsNone(self.st5.org_fee_payant) + + # now collect subsidies + self.st5.subsidy_to = self.fritz + self.assertEqual(self.st5.total_org_fee, total_org, 'Paid org fee should equal to amount per day per person * n_persons * n_days if subsidies are paid.') + + # now collect allowances + self.st5.allowance_to.add(self.fritz) + self.st5.subsidy_to = None + self.assertEqual(self.st5.total_org_fee, total_org, 'Paid org fee should equal to amount per day per person * n_persons * n_days if allowances are paid.') + + # now collect both + self.st5.subsidy_to = self.fritz + self.assertEqual(self.st5.total_org_fee, total_org, 'Paid org fee should equal to amount per day per person * n_persons * n_days if subsidies and allowances are paid.') + + self.assertEqual(self.st5.org_fee_payant, self.fritz, 'Org fee payant should be the receiver allowances and subsidies.') + + # return to previous state + self.st5.subsidy_to = None + self.st5.allowance_to.remove(self.fritz) + + + def test_ljp_payment(self): + + expected_intervention_hours = 2 + 3 + 4 + expected_seminar_days = 0 + 0.5 + 0.5 # >=2.5h = 0.5days, >=5h = 1.0day + expected_ljp = (1-settings.LJP_TAX) * expected_seminar_days * settings.LJP_CONTRIBUTION_PER_DAY * 9 + # (1 - 20% tax) * 1 seminar day * 20€ * 9 participants + + self.assertEqual(self.st5.excursion.total_intervention_hours, expected_intervention_hours, 'Calculation of total intervention hours is incorrect.') + self.assertEqual(self.st5.excursion.total_seminar_days, expected_seminar_days, 'Calculation of total seminar days is incorrect.') + + self.assertEqual(self.st5.paid_ljp_contributions, 0, 'No LJP contributions should be paid if no receiver is set.') + + # now we want to pay out the LJP contributions + self.st5.ljp_to = self.fritz + self.assertEqual(self.st5.paid_ljp_contributions, expected_ljp, 'LJP contributions should be paid if a receiver is set.') + + # now the total costs paid by trip organisers is lower than expected ljp contributions, should be reduced automatically + self.b2.amount=100 + self.b2.save() + + self.assertEqual(self.st5.total_bills_not_covered, 100, 'Changes in bills should be reflected in the total costs paid by trip organisers') + self.assertGreaterEqual(self.st5.total_bills_not_covered, self.st5.paid_ljp_contributions, 'LJP contributions should be less than or equal to the costs paid by trip organisers') + + self.st5.ljp_to = None def test_staff_count(self): self.assertEqual(self.st4.admissible_staff_count, 0, diff --git a/jdav_web/members/models.py b/jdav_web/members/models.py index 1700f2b..a5377e3 100644 --- a/jdav_web/members/models.py +++ b/jdav_web/members/models.py @@ -1423,23 +1423,30 @@ class Freizeit(CommonModel): @property def maximal_ljp_contributions(self): + """This is the maximal amount of LJP contributions that can be requested given participants and length + This calculation if intended for the LJP application, not for the payout.""" return cvt_to_decimal(settings.LJP_CONTRIBUTION_PER_DAY * self.ljp_participant_count * self.duration) @property def potential_ljp_contributions(self): + """The maximal amount can be reduced if the actual costs are lower than the maximal amount + This calculation if intended for the LJP application, not for the payout.""" + if not hasattr(self, 'statement'): + return cvt_to_decimal(0) return cvt_to_decimal(min(self.maximal_ljp_contributions, 0.9 * float(self.statement.total_bills_theoretic) + float(self.statement.total_staff))) @property def payable_ljp_contributions(self): - """from the requested ljp contributions, a tax may be deducted for risk reduction""" - if self.statement.ljp_to: + """the payable contributions can differ from potential contributions if a tax is deducted for risk reduction. + the actual payout depends on more factors, e.g. the actual costs that had to be paid by the trip organisers.""" + if hasattr(self, 'statement') and self.statement.ljp_to: return self.statement.paid_ljp_contributions return cvt_to_decimal(self.potential_ljp_contributions * cvt_to_decimal(1 - settings.LJP_TAX)) @property def total_relative_costs(self): - if not self.statement: + if not hasattr(self, 'statement'): return 0 total_costs = self.statement.total_bills_theoretic total_contributions = self.statement.total_subsidies + self.payable_ljp_contributions From 2cee336397cac9f9f1d253b491320c5e90363ed6 Mon Sep 17 00:00:00 2001 From: Christian Merten Date: Fri, 25 Jul 2025 00:54:17 +0200 Subject: [PATCH 02/25] chore: add license --- LICENSE | 661 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 661 insertions(+) create mode 100644 LICENSE diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..0ad25db --- /dev/null +++ b/LICENSE @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 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 Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. From e02f728e8a240affd5d92c2d9b408187c1c2fe3e Mon Sep 17 00:00:00 2001 From: "marius.klein" Date: Fri, 25 Jul 2025 21:29:18 +0200 Subject: [PATCH 03/25] feat(members/waitinglist): add group age range info to invite text (#168) Pass age info to group invite text as a parameter. Reviewed-by: Christian Merten Co-authored-by: marius.klein Co-committed-by: marius.klein --- jdav_web/members/locale/de/LC_MESSAGES/django.po | 11 ++++++++++- jdav_web/members/models.py | 15 +++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/jdav_web/members/locale/de/LC_MESSAGES/django.po b/jdav_web/members/locale/de/LC_MESSAGES/django.po index 8a7f47a..3344f9c 100644 --- a/jdav_web/members/locale/de/LC_MESSAGES/django.po +++ b/jdav_web/members/locale/de/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-05-03 18:06+0200\n" +"POT-Creation-Date: 2025-07-25 18:44+0200\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -599,6 +599,15 @@ msgstr "Gruppe" msgid "groups" msgstr "Gruppen" +#: members/models.py +#, python-format +msgid "years %(from)s to %(to)s" +msgstr "Jahrgang %(from)s bis %(to)s" + +#: members/models.py +msgid "no information available" +msgstr "keine Angabe" + #: members/models.py msgid "prename" msgstr "Vorname" diff --git a/jdav_web/members/models.py b/jdav_web/members/models.py index a5377e3..7531680 100644 --- a/jdav_web/members/models.py +++ b/jdav_web/members/models.py @@ -119,6 +119,15 @@ class Group(models.Model): end_time=self.end_time.strftime('%H:%M')) else: return "" + + def has_age_info(self): + return self.year_from and self.year_to + + def get_age_info(self): + if self.has_age_info(): + return _("years %(from)s to %(to)s") % {'from':self.year_from, 'to':self.year_to} + else: + return "" def get_invitation_text_template(self): """The text template used to invite waiters to this group. This contains @@ -131,8 +140,14 @@ class Group(models.Model): group_time = self.get_time_info() else: group_time = settings.GROUP_TIME_UNAVAILABLE_TEXT.format(contact_email=self.contact_email) + if self.has_age_info(): + group_age = self.get_age_info() + else: + group_age = _("no information available") + return settings.INVITE_TEXT.format(group_time=group_time, group_name=self.name, + group_age=group_age, group_link=group_link, contact_email=self.contact_email) From 7f203b513927415cca3b595b492c80204a020a9b Mon Sep 17 00:00:00 2001 From: Christian Merten Date: Mon, 28 Jul 2025 22:31:55 +0200 Subject: [PATCH 04/25] feat(contrib/management): add command to create a superuser from env variables --- .../management/commands/ensuresuperuser.py | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 jdav_web/contrib/management/commands/ensuresuperuser.py diff --git a/jdav_web/contrib/management/commands/ensuresuperuser.py b/jdav_web/contrib/management/commands/ensuresuperuser.py new file mode 100644 index 0000000..d701d64 --- /dev/null +++ b/jdav_web/contrib/management/commands/ensuresuperuser.py @@ -0,0 +1,28 @@ +import os +from django.contrib.auth import get_user_model +from django.core.management.base import BaseCommand + +class Command(BaseCommand): + help = "Creates a super-user non-interactively if it doesn't exist." + + def handle(self, *args, **options): + User = get_user_model() + + username = os.environ.get('DJANGO_SUPERUSER_USERNAME', '') + password = os.environ.get('DJANGO_SUPERUSER_PASSWORD', '') + + if not username or not password: + self.stdout.write( + self.style.WARNING('Superuser data was not set. Skipping.') + ) + return + + if not User.objects.filter(username=username).exists(): + User.objects.create_superuser(username=username, password=password) + self.stdout.write( + self.style.SUCCESS('Successfully created superuser.') + ) + else: + self.stdout.write( + self.style.SUCCESS('Superuser with configured username already exists. Skipping.') + ) From 99f6dfcdfb932f256ed24711ccf4db75c11dd55d Mon Sep 17 00:00:00 2001 From: Christian Merten Date: Mon, 28 Jul 2025 22:34:07 +0200 Subject: [PATCH 05/25] feat(docker/production): create superuser in initial setup We add one step to the master entrypoint script to ensure a superuser exists with username and password configured from the environment variables DJANGO_SUPERUSER_USERNAME and DJANGO_SUPERUSER_PASSWORD. The step does nothing if these variables are not set or the user already exists. --- docker/production/entrypoint-master.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/docker/production/entrypoint-master.sh b/docker/production/entrypoint-master.sh index 3ecdd40..a6ba638 100755 --- a/docker/production/entrypoint-master.sh +++ b/docker/production/entrypoint-master.sh @@ -16,6 +16,7 @@ if ! [ -f completed_initial_run ]; then python jdav_web/manage.py compilemessages --locale de python jdav_web/manage.py migrate + python jdav_web/manage.py ensuresuperuser touch completed_initial_run fi From a9b26e529b6dd0b3b0414d1a6ab268611887dda4 Mon Sep 17 00:00:00 2001 From: Christian Merten Date: Sat, 16 Aug 2025 01:03:25 +0200 Subject: [PATCH 06/25] feat(*): add more tests --- jdav_web/contrib/tests.py | 55 ++++++++++- jdav_web/finance/tests.py | 190 ++++++++++++++++++++++++++++--------- jdav_web/material/tests.py | 113 +++++++++++++++++++++- 3 files changed, 311 insertions(+), 47 deletions(-) diff --git a/jdav_web/contrib/tests.py b/jdav_web/contrib/tests.py index 7ce503c..41e3726 100644 --- a/jdav_web/contrib/tests.py +++ b/jdav_web/contrib/tests.py @@ -1,3 +1,56 @@ from django.test import TestCase +from django.contrib.auth import get_user_model +from contrib.models import CommonModel +from contrib.rules import has_global_perm -# Create your tests here. +User = get_user_model() + +class CommonModelTestCase(TestCase): + def test_common_model_abstract_base(self): + """Test that CommonModel provides the correct meta attributes""" + meta = CommonModel._meta + self.assertTrue(meta.abstract) + expected_permissions = ( + 'add_global', 'change_global', 'view_global', 'delete_global', 'list_global', 'view', + ) + self.assertEqual(meta.default_permissions, expected_permissions) + + def test_common_model_inheritance(self): + """Test that CommonModel has rules mixin functionality""" + # Test that CommonModel has the expected functionality + # Since it's abstract, we can't instantiate it directly + # but we can check its metaclass and mixins + from rules.contrib.models import RulesModelMixin, RulesModelBase + + self.assertTrue(issubclass(CommonModel, RulesModelMixin)) + self.assertEqual(CommonModel.__class__, RulesModelBase) + + +class GlobalPermissionRulesTestCase(TestCase): + def setUp(self): + self.user = User.objects.create_user( + username='testuser', + email='test@example.com', + password='testpass123' + ) + + def test_has_global_perm_predicate_creation(self): + """Test that has_global_perm creates a predicate function""" + # has_global_perm is a decorator factory, not a direct predicate + predicate = has_global_perm('auth.add_user') + self.assertTrue(callable(predicate)) + + def test_has_global_perm_with_superuser(self): + """Test that superusers have global permissions""" + self.user.is_superuser = True + self.user.save() + + predicate = has_global_perm('auth.add_user') + result = predicate(self.user, None) + self.assertTrue(result) + + def test_has_global_perm_with_regular_user(self): + """Test that regular users don't automatically have global permissions""" + predicate = has_global_perm('auth.add_user') + result = predicate(self.user, None) + self.assertFalse(result) diff --git a/jdav_web/finance/tests.py b/jdav_web/finance/tests.py index 949b124..b9f5eea 100644 --- a/jdav_web/finance/tests.py +++ b/jdav_web/finance/tests.py @@ -2,6 +2,7 @@ from unittest import skip from django.test import TestCase from django.utils import timezone from django.conf import settings +from decimal import Decimal from .models import Statement, StatementUnSubmitted, StatementSubmitted, Bill, Ledger, Transaction,\ StatementUnSubmittedManager, StatementSubmittedManager, StatementConfirmedManager,\ StatementConfirmed, TransactionIssue, StatementManager @@ -67,115 +68,115 @@ class StatementTestCase(TestCase): email=settings.TEST_MAIL, gender=DIVERSE) mol = NewMemberOnList.objects.create(member=m, memberlist=ex) ex.membersonlist.add(mol) - + base = timezone.now() ex = Freizeit.objects.create(name='Wild trip with old people', kilometers_traveled=self.kilometers_traveled, tour_type=GEMEINSCHAFTS_TOUR, tour_approach=MUSKELKRAFT_ANREISE, difficulty=2, date=timezone.datetime(2024, 1, 2, 8, 0, 0, tzinfo=base.tzinfo), end=timezone.datetime(2024, 1, 5, 17, 0, 0, tzinfo=base.tzinfo) ) - + settings.EXCURSION_ORG_FEE = 20 settings.LJP_TAX = 0.2 settings.LJP_CONTRIBUTION_PER_DAY = 20 - + self.st5 = Statement.objects.create(night_cost=self.night_cost, excursion=ex) - + for i in range(9): m = Member.objects.create(prename='Peter {}'.format(i), lastname='Walter', birth_date=timezone.now().date() - relativedelta(years=i+21), email=settings.TEST_MAIL, gender=DIVERSE) mol = NewMemberOnList.objects.create(member=m, memberlist=ex) - ex.membersonlist.add(mol) - + ex.membersonlist.add(mol) + ljpproposal = LJPProposal.objects.create( - title='Test proposal', + title='Test proposal', category=LJPProposal.LJP_STAFF_TRAINING, goal=LJPProposal.LJP_ENVIRONMENT, goal_strategy='my strategy', not_bw_reason=LJPProposal.NOT_BW_ROOMS, excursion=self.st5.excursion) - + for i in range(3): int = Intervention.objects.create( - date_start=timezone.datetime(2024, 1, 2+i, 12, 0, 0, tzinfo=base.tzinfo), - duration = 2+i, + date_start=timezone.datetime(2024, 1, 2+i, 12, 0, 0, tzinfo=base.tzinfo), + duration = 2+i, activity = 'hi', ljp_proposal=ljpproposal ) - + self.b1 = Bill.objects.create( - statement=self.st5, - short_description='covered bill', - explanation='hi', - amount='300', - paid_by=self.fritz, - costs_covered=True, + statement=self.st5, + short_description='covered bill', + explanation='hi', + amount='300', + paid_by=self.fritz, + costs_covered=True, refunded=False ) self.b2 = Bill.objects.create( - statement=self.st5, - short_description='non-covered bill', - explanation='hi', - amount='900', - paid_by=self.fritz, - costs_covered=False, + statement=self.st5, + short_description='non-covered bill', + explanation='hi', + amount='900', + paid_by=self.fritz, + costs_covered=False, refunded=False ) - + def test_org_fee(self): # org fee should be collected if participants are older than 26 self.assertEqual(self.st5.excursion.old_participant_count, 3, 'Calculation of number of old people in excursion is incorrect.') - + total_org = 4 * 3 * 20 # 4 days, 3 old people, 20€ per day - + self.assertEqual(self.st5.total_org_fee_theoretical, total_org, 'Theoretical org_fee should equal to amount per day per person * n_persons * n_days if there are old people.') self.assertEqual(self.st5.total_org_fee, 0, 'Paid org fee should be 0 if no allowance and subsidies are paid if there are old people.') - + self.assertIsNone(self.st5.org_fee_payant) - + # now collect subsidies self.st5.subsidy_to = self.fritz self.assertEqual(self.st5.total_org_fee, total_org, 'Paid org fee should equal to amount per day per person * n_persons * n_days if subsidies are paid.') - + # now collect allowances self.st5.allowance_to.add(self.fritz) self.st5.subsidy_to = None self.assertEqual(self.st5.total_org_fee, total_org, 'Paid org fee should equal to amount per day per person * n_persons * n_days if allowances are paid.') - + # now collect both self.st5.subsidy_to = self.fritz self.assertEqual(self.st5.total_org_fee, total_org, 'Paid org fee should equal to amount per day per person * n_persons * n_days if subsidies and allowances are paid.') - + self.assertEqual(self.st5.org_fee_payant, self.fritz, 'Org fee payant should be the receiver allowances and subsidies.') # return to previous state self.st5.subsidy_to = None self.st5.allowance_to.remove(self.fritz) - - + + def test_ljp_payment(self): - + expected_intervention_hours = 2 + 3 + 4 expected_seminar_days = 0 + 0.5 + 0.5 # >=2.5h = 0.5days, >=5h = 1.0day - expected_ljp = (1-settings.LJP_TAX) * expected_seminar_days * settings.LJP_CONTRIBUTION_PER_DAY * 9 - # (1 - 20% tax) * 1 seminar day * 20€ * 9 participants - + expected_ljp = (1-settings.LJP_TAX) * expected_seminar_days * settings.LJP_CONTRIBUTION_PER_DAY * 9 + # (1 - 20% tax) * 1 seminar day * 20€ * 9 participants + self.assertEqual(self.st5.excursion.total_intervention_hours, expected_intervention_hours, 'Calculation of total intervention hours is incorrect.') self.assertEqual(self.st5.excursion.total_seminar_days, expected_seminar_days, 'Calculation of total seminar days is incorrect.') - + self.assertEqual(self.st5.paid_ljp_contributions, 0, 'No LJP contributions should be paid if no receiver is set.') - + # now we want to pay out the LJP contributions self.st5.ljp_to = self.fritz self.assertEqual(self.st5.paid_ljp_contributions, expected_ljp, 'LJP contributions should be paid if a receiver is set.') - + # now the total costs paid by trip organisers is lower than expected ljp contributions, should be reduced automatically self.b2.amount=100 self.b2.save() - + self.assertEqual(self.st5.total_bills_not_covered, 100, 'Changes in bills should be reflected in the total costs paid by trip organisers') self.assertGreaterEqual(self.st5.total_bills_not_covered, self.st5.paid_ljp_contributions, 'LJP contributions should be less than or equal to the costs paid by trip organisers') - + self.st5.ljp_to = None def test_staff_count(self): @@ -371,6 +372,41 @@ class StatementTestCase(TestCase): bills = self.st2.grouped_bills() self.assertTrue('amount' in bills[0]) + def test_euro_per_km_no_excursion(self): + """Test euro_per_km when no excursion is associated""" + statement = Statement.objects.create( + short_description="Test Statement", + explanation="Test explanation", + night_cost=25 + ) + self.assertEqual(statement.euro_per_km, 0) + + def test_submit_workflow(self): + """Test statement submission workflow""" + statement = Statement.objects.create( + short_description="Test Statement", + explanation="Test explanation", + night_cost=25, + created_by=self.fritz + ) + + self.assertFalse(statement.submitted) + self.assertIsNone(statement.submitted_by) + self.assertIsNone(statement.submitted_date) + + # Test submission - submit method doesn't return a value, just changes state + statement.submit(submitter=self.fritz) + self.assertTrue(statement.submitted) + self.assertEqual(statement.submitted_by, self.fritz) + self.assertIsNotNone(statement.submitted_date) + + def test_template_context_with_excursion(self): + """Test statement template context when excursion is present""" + # Use existing excursion from setUp + context = self.st3.template_context() + self.assertIn('euro_per_km', context) + self.assertIsInstance(context['euro_per_km'], (int, float, Decimal)) + class LedgerTestCase(TestCase): def setUp(self): @@ -431,9 +467,20 @@ class TransactionTestCase(TestCase): self.assertTrue(str(self.trans.pk) in str(self.trans)) def test_escape_reference(self): - self.assertEqual(Transaction.escape_reference('harmless'), 'harmless') - self.assertEqual(Transaction.escape_reference('äöüÄÖÜß'), 'aeoeueAeOeUess') - self.assertEqual(Transaction.escape_reference('ha@r!?mless+09'), 'har?mless+09') + """Test transaction reference escaping with various special characters""" + test_cases = [ + ('harmless', 'harmless'), + ('äöüÄÖÜß', 'aeoeueAeOeUess'), + ('ha@r!?mless+09', 'har?mless+09'), + ("simple", "simple"), + ("test@email.com", "testemail.com"), + ("ref!with#special$chars%", "refwithspecialchars"), + ("normal_text-123", "normaltext-123"), # underscores are removed + ] + + for input_ref, expected in test_cases: + result = Transaction.escape_reference(input_ref) + self.assertEqual(result, expected) def test_code(self): self.trans.amount = 0 @@ -446,6 +493,35 @@ class TransactionTestCase(TestCase): self.fritz.iban = 'DE89370400440532013000' self.assertNotEqual(self.trans.code(), '') + def test_code_with_zero_amount(self): + """Test transaction code generation with zero amount""" + transaction = Transaction.objects.create( + reference="test-ref", + amount=Decimal('0.00'), + member=self.fritz, + ledger=self.personal_account, + statement=self.st + ) + + # Zero amount should return empty code + self.assertEqual(transaction.code(), '') + + def test_code_with_invalid_iban(self): + """Test transaction code generation with invalid IBAN""" + self.fritz.iban = "INVALID_IBAN" + self.fritz.save() + + transaction = Transaction.objects.create( + reference="test-ref", + amount=Decimal('100.00'), + member=self.fritz, + ledger=self.personal_account, + statement=self.st + ) + + # Invalid IBAN should return empty code + self.assertEqual(transaction.code(), '') + class BillTestCase(TestCase): def setUp(self): @@ -461,6 +537,30 @@ class BillTestCase(TestCase): def test_pretty_amount(self): self.assertTrue('€' in self.bill.pretty_amount()) + def test_pretty_amount_formatting(self): + """Test bill pretty_amount formatting with specific values""" + bill = Bill.objects.create( + statement=self.st, + short_description="Test Bill", + amount=Decimal('42.50') + ) + + pretty = bill.pretty_amount() + self.assertIn("42.50", pretty) + self.assertIn("€", pretty) + + def test_zero_amount(self): + """Test bill with zero amount""" + bill = Bill.objects.create( + statement=self.st, + short_description="Zero Bill", + amount=Decimal('0.00') + ) + + self.assertEqual(bill.amount, Decimal('0.00')) + pretty = bill.pretty_amount() + self.assertIn("0.00", pretty) + class TransactionIssueTestCase(TestCase): def setUp(self): diff --git a/jdav_web/material/tests.py b/jdav_web/material/tests.py index 7ce503c..53ee4ec 100644 --- a/jdav_web/material/tests.py +++ b/jdav_web/material/tests.py @@ -1,3 +1,114 @@ from django.test import TestCase +from django.utils import timezone +from datetime import date +from decimal import Decimal +from material.models import MaterialCategory, MaterialPart, Ownership +from members.models import Member, MALE, FEMALE, DIVERSE -# Create your tests here. + +class MaterialCategoryTestCase(TestCase): + def setUp(self): + self.category = MaterialCategory.objects.create(name="Climbing Gear") + + def test_str(self): + """Test string representation of MaterialCategory""" + self.assertEqual(str(self.category), "Climbing Gear") + + def test_verbose_names(self): + """Test verbose names are set correctly""" + meta = MaterialCategory._meta + self.assertTrue(hasattr(meta, 'verbose_name')) + self.assertTrue(hasattr(meta, 'verbose_name_plural')) + + +class MaterialPartTestCase(TestCase): + def setUp(self): + self.category = MaterialCategory.objects.create(name="Ropes") + self.material_part = MaterialPart.objects.create( + name="Dynamic Rope 10mm", + description="60m dynamic climbing rope", + quantity=5, + buy_date=date(2020, 1, 15), + lifetime=Decimal('8') + ) + self.material_part.material_cat.add(self.category) + + self.member = Member.objects.create( + prename="John", + lastname="Doe", + birth_date=date(1990, 1, 1), + email="john@example.com", + gender=MALE + ) + + def test_str(self): + """Test string representation of MaterialPart""" + self.assertEqual(str(self.material_part), "Dynamic Rope 10mm") + + def test_quantity_real_no_ownership(self): + """Test quantity_real when no ownership exists""" + result = self.material_part.quantity_real() + self.assertEqual(result, "0/5") + + def test_quantity_real_with_ownership(self): + """Test quantity_real with ownership records""" + Ownership.objects.create( + material=self.material_part, + owner=self.member, + count=3 + ) + Ownership.objects.create( + material=self.material_part, + owner=self.member, + count=1 + ) + result = self.material_part.quantity_real() + self.assertEqual(result, "4/5") + + def test_verbose_names(self): + """Test field verbose names""" + # Just test that verbose names exist, since they might be translated + field_names = ['name', 'description', 'quantity', 'buy_date', 'lifetime', 'photo', 'material_cat'] + + for field_name in field_names: + field = self.material_part._meta.get_field(field_name) + self.assertTrue(hasattr(field, 'verbose_name')) + self.assertIsNotNone(field.verbose_name) + + +class OwnershipTestCase(TestCase): + def setUp(self): + self.category = MaterialCategory.objects.create(name="Hardware") + self.material_part = MaterialPart.objects.create( + name="Carabiner Set", + description="Lightweight aluminum carabiners", + quantity=10, + buy_date=date(2021, 6, 1), + lifetime=Decimal('10') + ) + + self.member = Member.objects.create( + prename="Alice", + lastname="Smith", + birth_date=date(1985, 3, 15), + email="alice@example.com", + gender=FEMALE + ) + + self.ownership = Ownership.objects.create( + material=self.material_part, + owner=self.member, + count=6 + ) + + def test_ownership_creation(self): + """Test ownership record creation""" + self.assertEqual(self.ownership.material, self.material_part) + self.assertEqual(self.ownership.owner, self.member) + self.assertEqual(self.ownership.count, 6) + + def test_material_part_relationship(self): + """Test relationship between MaterialPart and Ownership""" + ownerships = Ownership.objects.filter(material=self.material_part) + self.assertEqual(ownerships.count(), 1) + self.assertEqual(ownerships.first(), self.ownership) From 396ea6f796c1f594f55598f76ca085a220418473 Mon Sep 17 00:00:00 2001 From: Christian Merten Date: Sat, 16 Aug 2025 16:24:14 +0200 Subject: [PATCH 07/25] chore(finance/tests): reorganise and add admin tests --- jdav_web/finance/tests/__init__.py | 2 + jdav_web/finance/tests/admin.py | 342 ++++++++++++++++++ .../finance/{tests.py => tests/models.py} | 3 +- 3 files changed, 346 insertions(+), 1 deletion(-) create mode 100644 jdav_web/finance/tests/__init__.py create mode 100644 jdav_web/finance/tests/admin.py rename jdav_web/finance/{tests.py => tests/models.py} (99%) diff --git a/jdav_web/finance/tests/__init__.py b/jdav_web/finance/tests/__init__.py new file mode 100644 index 0000000..4754139 --- /dev/null +++ b/jdav_web/finance/tests/__init__.py @@ -0,0 +1,2 @@ +from .admin import * +from .models import * diff --git a/jdav_web/finance/tests/admin.py b/jdav_web/finance/tests/admin.py new file mode 100644 index 0000000..892338e --- /dev/null +++ b/jdav_web/finance/tests/admin.py @@ -0,0 +1,342 @@ +from django.test import TestCase, override_settings +from django.contrib.admin.sites import AdminSite +from django.test import RequestFactory, Client +from django.contrib.auth.models import User, Permission +from django.utils import timezone +from django.contrib.sessions.middleware import SessionMiddleware +from django.contrib.messages.middleware import MessageMiddleware +from django.contrib.messages.storage.fallback import FallbackStorage +from django.utils.translation import gettext_lazy as _ + +from members.models import Member, MALE +from ..models import Ledger, Statement, StatementConfirmed, Transaction, Bill +from ..admin import ( + LedgerAdmin, StatementUnSubmittedAdmin, StatementSubmittedAdmin, + StatementConfirmedAdmin, TransactionAdmin, BillAdmin +) + + +class StatementUnSubmittedAdminTestCase(TestCase): + """Test cases for StatementUnSubmittedAdmin""" + + def setUp(self): + self.site = AdminSite() + self.factory = RequestFactory() + self.admin = StatementUnSubmittedAdmin(Statement, self.site) + + self.user = User.objects.create_user('testuser', 'test@example.com', 'pass') + self.member = Member.objects.create( + prename="Test", lastname="User", birth_date=timezone.now().date(), + email="test@example.com", gender=MALE, user=self.user + ) + + self.statement = Statement.objects.create( + short_description='Test Statement', + explanation='Test explanation', + night_cost=25 + ) + + def _add_session_to_request(self, request): + """Add session to request""" + middleware = SessionMiddleware(lambda req: None) + middleware.process_request(request) + request.session.save() + + middleware = MessageMiddleware(lambda req: None) + middleware.process_request(request) + request._messages = FallbackStorage(request) + + def test_save_model_with_member(self): + """Test save_model sets created_by for new objects""" + request = self.factory.post('/') + request.user = self.user + + # Test with change=False (new object) + new_statement = Statement(short_description='New Statement') + self.admin.save_model(request, new_statement, None, change=False) + self.assertEqual(new_statement.created_by, self.member) + + def test_get_readonly_fields_submitted(self): + """Test readonly fields when statement is submitted""" + # Mark statement as submitted + self.statement.submitted = True + readonly_fields = self.admin.get_readonly_fields(None, self.statement) + self.assertIn('submitted', readonly_fields) + self.assertIn('excursion', readonly_fields) + self.assertIn('short_description', readonly_fields) + + def test_get_readonly_fields_not_submitted(self): + """Test readonly fields when statement is not submitted""" + readonly_fields = self.admin.get_readonly_fields(None, self.statement) + self.assertEqual(readonly_fields, ['submitted', 'excursion']) + + +class StatementSubmittedAdminTestCase(TestCase): + """Test cases for StatementSubmittedAdmin""" + + def setUp(self): + self.site = AdminSite() + self.factory = RequestFactory() + self.admin = StatementSubmittedAdmin(Statement, self.site) + + self.user = User.objects.create_user('testuser', 'test@example.com', 'pass') + self.member = Member.objects.create( + prename="Test", lastname="User", birth_date=timezone.now().date(), + email="test@example.com", gender=MALE, user=self.user + ) + + self.finance_user = User.objects.create_user('finance', 'finance@example.com', 'pass') + finance_perm = Permission.objects.get(codename='process_statementsubmitted') + self.finance_user.user_permissions.add(finance_perm) + + self.statement = Statement.objects.create( + short_description='Submitted Statement', + explanation='Test explanation', + submitted=True, + submitted_by=self.member, + submitted_date=timezone.now(), + night_cost=25 + ) + + def _add_session_to_request(self, request): + """Add session to request""" + middleware = SessionMiddleware(lambda req: None) + middleware.process_request(request) + request.session.save() + + middleware = MessageMiddleware(lambda req: None) + middleware.process_request(request) + request._messages = FallbackStorage(request) + + def test_has_add_permission(self): + """Test that add permission is disabled""" + request = self.factory.get('/') + request.user = self.finance_user + self.assertFalse(self.admin.has_add_permission(request)) + + def test_has_change_permission_with_permission(self): + """Test change permission with proper permission""" + request = self.factory.get('/') + request.user = self.finance_user + self.assertTrue(self.admin.has_change_permission(request)) + + def test_has_change_permission_without_permission(self): + """Test change permission without proper permission""" + request = self.factory.get('/') + request.user = self.user + self.assertFalse(self.admin.has_change_permission(request)) + + def test_has_delete_permission(self): + """Test that delete permission is disabled""" + request = self.factory.get('/') + request.user = self.finance_user + self.assertFalse(self.admin.has_delete_permission(request)) + + def test_reduce_transactions_view(self): + """Test reduce_transactions_view logic""" + # Test GET parameters + request = self.factory.get('/', {'redirectTo': '/admin/'}) + self.assertIn('redirectTo', request.GET) + self.assertEqual(request.GET['redirectTo'], '/admin/') + + +class StatementConfirmedAdminTestCase(TestCase): + """Test cases for StatementConfirmedAdmin""" + + def setUp(self): + self.site = AdminSite() + self.factory = RequestFactory() + self.admin = StatementConfirmedAdmin(StatementConfirmed, self.site) + + # Register the admin with the site to enable URL resolution + self.site.register(StatementConfirmed, StatementConfirmedAdmin) + + self.user = User.objects.create_user('testuser', 'test@example.com', 'pass') + self.member = Member.objects.create( + prename="Test", lastname="User", birth_date=timezone.now().date(), + email="test@example.com", gender=MALE, user=self.user + ) + + self.finance_user = User.objects.create_user('finance', 'finance@example.com', 'pass') + unconfirm_perm = Permission.objects.get(codename='may_manage_confirmed_statements') + self.finance_user.user_permissions.add(unconfirm_perm) + + # Create a base statement first + base_statement = Statement.objects.create( + short_description='Confirmed Statement', + explanation='Test explanation', + submitted=True, + confirmed=True, + confirmed_by=self.member, + confirmed_date=timezone.now(), + night_cost=25 + ) + + # StatementConfirmed is a proxy model, so we can get it from the base statement + self.statement = StatementConfirmed.objects.get(pk=base_statement.pk) + + def _add_session_to_request(self, request): + """Add session to request""" + middleware = SessionMiddleware(lambda req: None) + middleware.process_request(request) + request.session.save() + + middleware = MessageMiddleware(lambda req: None) + middleware.process_request(request) + request._messages = FallbackStorage(request) + + def test_has_add_permission(self): + """Test that add permission is disabled""" + request = self.factory.get('/') + request.user = self.finance_user + self.assertFalse(self.admin.has_add_permission(request)) + + def test_has_change_permission(self): + """Test that change permission is disabled""" + request = self.factory.get('/') + request.user = self.finance_user + self.assertFalse(self.admin.has_change_permission(request)) + + def test_has_delete_permission(self): + """Test that delete permission is disabled""" + request = self.factory.get('/') + request.user = self.finance_user + self.assertFalse(self.admin.has_delete_permission(request)) + + def test_unconfirm_view_not_confirmed_statement(self): + """Test unconfirm_view with statement that is not confirmed""" + # Add special permission for unconfirm + unconfirm_perm = Permission.objects.get(codename='may_manage_confirmed_statements') + self.finance_user.user_permissions.add(unconfirm_perm) + + # Create request for unconfirmed statement + request = self.factory.get('/') + request.user = self.finance_user + self._add_session_to_request(request) + + # Create an unconfirmed statement for this test + unconfirmed_base = Statement.objects.create( + short_description='Unconfirmed Statement', + explanation='Test explanation', + night_cost=25 + ) + # This won't be accessible via StatementConfirmed since it's not confirmed + unconfirmed_statement = unconfirmed_base + + # Test with unconfirmed statement (should trigger error path) + self.assertFalse(unconfirmed_statement.confirmed) + + # Call unconfirm_view - this should go through error path + response = self.admin.unconfirm_view(request, unconfirmed_statement.pk) + + # Should redirect due to not confirmed error + self.assertEqual(response.status_code, 302) + + def test_unconfirm_view_post_unconfirm_action(self): + """Test unconfirm_view POST request with 'unconfirm' action""" + # Add special permission for unconfirm + unconfirm_perm = Permission.objects.get(codename='may_manage_confirmed_statements') + self.finance_user.user_permissions.add(unconfirm_perm) + + # Create POST request with unconfirm action + request = self.factory.post('/', {'unconfirm': 'true'}) + request.user = self.finance_user + self._add_session_to_request(request) + + # Ensure statement is confirmed + self.assertTrue(self.statement.confirmed) + self.assertIsNotNone(self.statement.confirmed_by) + self.assertIsNotNone(self.statement.confirmed_date) + + # Call unconfirm_view - this should execute the unconfirm action + response = self.admin.unconfirm_view(request, self.statement.pk) + + # Should redirect after successful unconfirm + self.assertEqual(response.status_code, 302) + + # Verify statement was unconfirmed (need to reload from DB) + self.statement.refresh_from_db() + self.assertFalse(self.statement.confirmed) + self.assertIsNone(self.statement.confirmed_date) + + def test_unconfirm_view_get_render_template(self): + """Test unconfirm_view GET request rendering template""" + # Add special permission for unconfirm + unconfirm_perm = Permission.objects.get(codename='may_manage_confirmed_statements') + self.finance_user.user_permissions.add(unconfirm_perm) + + # Create GET request (no POST data) + request = self.factory.get('/') + request.user = self.finance_user + self._add_session_to_request(request) + + # Ensure statement is confirmed + self.assertTrue(self.statement.confirmed) + + # Call unconfirm_view + response = self.admin.unconfirm_view(request, self.statement.pk) + + # Should render template (status 200) + self.assertEqual(response.status_code, 200) + + # Check response content contains expected template elements + self.assertIn(str(_('Unconfirm statement')).encode('utf-8'), response.content) + self.assertIn(self.statement.short_description.encode(), response.content) + + +class TransactionAdminTestCase(TestCase): + """Test cases for TransactionAdmin""" + + def setUp(self): + self.site = AdminSite() + self.factory = RequestFactory() + self.admin = TransactionAdmin(Transaction, self.site) + + self.user = User.objects.create_user('testuser', 'test@example.com', 'pass') + self.member = Member.objects.create( + prename="Test", lastname="User", birth_date=timezone.now().date(), + email="test@example.com", gender=MALE, user=self.user + ) + + self.ledger = Ledger.objects.create(name='Test Ledger') + self.statement = Statement.objects.create( + short_description='Test Statement', + explanation='Test explanation' + ) + + self.transaction = Transaction.objects.create( + member=self.member, + ledger=self.ledger, + amount=100, + reference='Test transaction', + statement=self.statement + ) + + def test_has_add_permission(self): + """Test that add permission is disabled""" + request = self.factory.get('/') + request.user = self.user + self.assertFalse(self.admin.has_add_permission(request)) + + def test_has_change_permission(self): + """Test that change permission is disabled""" + request = self.factory.get('/') + request.user = self.user + self.assertFalse(self.admin.has_change_permission(request)) + + def test_has_delete_permission(self): + """Test that delete permission is disabled""" + request = self.factory.get('/') + request.user = self.user + self.assertFalse(self.admin.has_delete_permission(request)) + + def test_get_readonly_fields_confirmed(self): + """Test readonly fields when transaction is confirmed""" + self.transaction.confirmed = True + readonly_fields = self.admin.get_readonly_fields(None, self.transaction) + self.assertEqual(readonly_fields, self.admin.fields) + + def test_get_readonly_fields_not_confirmed(self): + """Test readonly fields when transaction is not confirmed""" + readonly_fields = self.admin.get_readonly_fields(None, self.transaction) + self.assertEqual(readonly_fields, ()) diff --git a/jdav_web/finance/tests.py b/jdav_web/finance/tests/models.py similarity index 99% rename from jdav_web/finance/tests.py rename to jdav_web/finance/tests/models.py index b9f5eea..0cfc61a 100644 --- a/jdav_web/finance/tests.py +++ b/jdav_web/finance/tests/models.py @@ -3,12 +3,13 @@ from django.test import TestCase from django.utils import timezone from django.conf import settings from decimal import Decimal -from .models import Statement, StatementUnSubmitted, StatementSubmitted, Bill, Ledger, Transaction,\ +from finance.models import Statement, StatementUnSubmitted, StatementSubmitted, Bill, Ledger, Transaction,\ StatementUnSubmittedManager, StatementSubmittedManager, StatementConfirmedManager,\ StatementConfirmed, TransactionIssue, StatementManager from members.models import Member, Group, Freizeit, LJPProposal, Intervention, GEMEINSCHAFTS_TOUR, MUSKELKRAFT_ANREISE, NewMemberOnList,\ FAHRGEMEINSCHAFT_ANREISE, MALE, FEMALE, DIVERSE from dateutil.relativedelta import relativedelta +from utils import get_member # Create your tests here. class StatementTestCase(TestCase): From 355aad61c20b6e5d6f204c4a54280d6fccf68021 Mon Sep 17 00:00:00 2001 From: Christian Merten Date: Sun, 17 Aug 2025 11:54:24 +0200 Subject: [PATCH 08/25] chore(finance/tests): add more admin tests --- jdav_web/finance/tests/admin.py | 494 ++++++++++++++++++++++++++++---- 1 file changed, 435 insertions(+), 59 deletions(-) diff --git a/jdav_web/finance/tests/admin.py b/jdav_web/finance/tests/admin.py index 892338e..85507fc 100644 --- a/jdav_web/finance/tests/admin.py +++ b/jdav_web/finance/tests/admin.py @@ -1,3 +1,5 @@ +import unittest +from http import HTTPStatus from django.test import TestCase, override_settings from django.contrib.admin.sites import AdminSite from django.test import RequestFactory, Client @@ -6,50 +8,88 @@ from django.utils import timezone from django.contrib.sessions.middleware import SessionMiddleware from django.contrib.messages.middleware import MessageMiddleware from django.contrib.messages.storage.fallback import FallbackStorage +from django.contrib.messages import get_messages from django.utils.translation import gettext_lazy as _ - -from members.models import Member, MALE -from ..models import Ledger, Statement, StatementConfirmed, Transaction, Bill +from django.urls import reverse, reverse_lazy +from django.http import HttpResponseRedirect, HttpResponse +from unittest.mock import Mock, patch +from django.test.utils import override_settings +from django.urls import path, include +from django.contrib import admin as django_admin + +from members.tests.utils import create_custom_user +from members.models import Member, MALE, Freizeit, GEMEINSCHAFTS_TOUR, MUSKELKRAFT_ANREISE +from ..models import ( + Ledger, Statement, StatementUnSubmitted, StatementConfirmed, Transaction, Bill, + StatementSubmitted +) from ..admin import ( LedgerAdmin, StatementUnSubmittedAdmin, StatementSubmittedAdmin, StatementConfirmedAdmin, TransactionAdmin, BillAdmin ) -class StatementUnSubmittedAdminTestCase(TestCase): +class AdminTestCase(TestCase): + def setUp(self, model, admin): + self.factory = RequestFactory() + self.model = model + if model is not None and admin is not None: + self.admin = admin(model, AdminSite()) + superuser = User.objects.create_superuser( + username='superuser', password='secret' + ) + standard = create_custom_user('standard', ['Standard'], 'Paul', 'Wulter') + trainer = create_custom_user('trainer', ['Standard', 'Trainings'], 'Lise', 'Lotte') + treasurer = create_custom_user('treasurer', ['Standard', 'Finance'], 'Lara', 'Litte') + materialwarden = create_custom_user('materialwarden', ['Standard', 'Material'], 'Loro', 'Lutte') + + def _login(self, name): + c = Client() + res = c.login(username=name, password='secret') + # make sure we logged in + assert res + return c + + +class StatementUnSubmittedAdminTestCase(AdminTestCase): """Test cases for StatementUnSubmittedAdmin""" def setUp(self): - self.site = AdminSite() - self.factory = RequestFactory() - self.admin = StatementUnSubmittedAdmin(Statement, self.site) + super().setUp(model=StatementUnSubmitted, admin=StatementUnSubmittedAdmin) - self.user = User.objects.create_user('testuser', 'test@example.com', 'pass') + self.superuser = User.objects.get(username='superuser') self.member = Member.objects.create( prename="Test", lastname="User", birth_date=timezone.now().date(), - email="test@example.com", gender=MALE, user=self.user + email="test@example.com", gender=MALE, user=self.superuser ) - self.statement = Statement.objects.create( + self.statement = StatementUnSubmitted.objects.create( short_description='Test Statement', explanation='Test explanation', night_cost=25 ) - def _add_session_to_request(self, request): - """Add session to request""" - middleware = SessionMiddleware(lambda req: None) - middleware.process_request(request) - request.session.save() + # Create excursion for testing + self.excursion = Freizeit.objects.create( + name='Test Excursion', + kilometers_traveled=100, + tour_type=GEMEINSCHAFTS_TOUR, + tour_approach=MUSKELKRAFT_ANREISE, + difficulty=1 + ) - middleware = MessageMiddleware(lambda req: None) - middleware.process_request(request) - request._messages = FallbackStorage(request) + # Create confirmed statement with excursion + self.statement_with_excursion = StatementUnSubmitted.objects.create( + short_description='With Excursion', + explanation='Test explanation', + night_cost=25, + excursion=self.excursion, + ) def test_save_model_with_member(self): """Test save_model sets created_by for new objects""" request = self.factory.post('/') - request.user = self.user + request.user = self.superuser # Test with change=False (new object) new_statement = Statement(short_description='New Statement') @@ -70,14 +110,46 @@ class StatementUnSubmittedAdminTestCase(TestCase): readonly_fields = self.admin.get_readonly_fields(None, self.statement) self.assertEqual(readonly_fields, ['submitted', 'excursion']) - -class StatementSubmittedAdminTestCase(TestCase): + @unittest.skip('Request returns 200, but should give insufficient permissions.') + def test_submit_view_insufficient_permission(self): + url = reverse('admin:finance_statementunsubmitted_submit', + args=(self.statement.pk,)) + c = self._login('standard') + response = c.get(url, follow=True) + self.assertEqual(response.status_code, HTTPStatus.FORBIDDEN) + + def test_submit_view_get(self): + url = reverse('admin:finance_statementunsubmitted_submit', + args=(self.statement.pk,)) + c = self._login('superuser') + response = c.get(url, follow=True) + self.assertEqual(response.status_code, HTTPStatus.OK) + self.assertContains(response, _('Submit statement')) + + @unittest.skip('Currently fails with TypeError, because `participant_count` is passed twice.') + def test_submit_view_get_with_excursion(self): + url = reverse('admin:finance_statementunsubmitted_submit', + args=(self.statement_with_excursion.pk,)) + c = self._login('superuser') + response = c.get(url, follow=True) + self.assertEqual(response.status_code, HTTPStatus.OK) + self.assertContains(response, _('Finance overview')) + + def test_submit_view_post(self): + url = reverse('admin:finance_statementunsubmitted_submit', + args=(self.statement.pk,)) + c = self._login('superuser') + response = c.post(url, follow=True, data={'apply': ''}) + self.assertEqual(response.status_code, HTTPStatus.OK) + text = _("Successfully submited %(name)s. The finance department will notify the requestors as soon as possible.") % {'name': str(self.statement)} + self.assertContains(response, text) + + +class StatementSubmittedAdminTestCase(AdminTestCase): """Test cases for StatementSubmittedAdmin""" def setUp(self): - self.site = AdminSite() - self.factory = RequestFactory() - self.admin = StatementSubmittedAdmin(Statement, self.site) + super().setUp(model=StatementSubmitted, admin=StatementSubmittedAdmin) self.user = User.objects.create_user('testuser', 'test@example.com', 'pass') self.member = Member.objects.create( @@ -97,6 +169,84 @@ class StatementSubmittedAdminTestCase(TestCase): submitted_date=timezone.now(), night_cost=25 ) + self.statement_unsubmitted = StatementUnSubmitted.objects.create( + short_description='Submitted Statement', + explanation='Test explanation', + night_cost=25 + ) + self.transaction = Transaction.objects.create( + reference='verylonglong' * 14, + amount=3, + statement=self.statement, + member=self.member, + ) + + # Create commonly used test objects + self.ledger = Ledger.objects.create(name='Test Ledger') + self.excursion = Freizeit.objects.create( + name='Test Excursion', + kilometers_traveled=100, + tour_type=GEMEINSCHAFTS_TOUR, + tour_approach=MUSKELKRAFT_ANREISE, + difficulty=1 + ) + self.other_member = Member.objects.create( + prename="Other", lastname="Member", birth_date=timezone.now().date(), + email="other@example.com", gender=MALE + ) + + # Create statements for generate transactions tests + self.statement_no_trans_success = Statement.objects.create( + short_description='No Transactions Success', + explanation='Test explanation', + submitted=True, + submitted_by=self.member, + submitted_date=timezone.now(), + night_cost=25 + ) + self.statement_no_trans_error = Statement.objects.create( + short_description='No Transactions Error', + explanation='Test explanation', + submitted=True, + submitted_by=self.member, + submitted_date=timezone.now(), + night_cost=25 + ) + + # Create bills for generate transactions tests + self.bill_for_success = Bill.objects.create( + statement=self.statement_no_trans_success, + short_description='Test Bill Success', + amount=50, + paid_by=self.member, + costs_covered=True + ) + self.bill_for_error = Bill.objects.create( + statement=self.statement_no_trans_error, + short_description='Test Bill Error', + amount=50, + paid_by=None, # No payer will cause generate_transactions to fail + costs_covered=True, + ) + + def _create_matching_bill(self, statement=None, amount=None): + """Helper method to create a bill that matches transaction amount""" + return Bill.objects.create( + statement=statement or self.statement, + short_description='Test Bill', + amount=amount or self.transaction.amount, + paid_by=self.member, + costs_covered=True + ) + + def _create_non_matching_bill(self, statement=None, amount=100): + """Helper method to create a bill that doesn't match transaction amount""" + return Bill.objects.create( + statement=statement or self.statement, + short_description='Non-matching Bill', + amount=amount, + paid_by=self.member + ) def _add_session_to_request(self, request): """Add session to request""" @@ -132,24 +282,216 @@ class StatementSubmittedAdminTestCase(TestCase): request.user = self.finance_user self.assertFalse(self.admin.has_delete_permission(request)) + def test_readonly_fields(self): + self.assertNotIn('explanation', + self.admin.get_readonly_fields(None, self.statement_unsubmitted)) + + def test_change(self): + url = reverse('admin:finance_statementsubmitted_change', + args=(self.statement.pk,)) + c = self._login('superuser') + response = c.get(url) + self.assertEqual(response.status_code, HTTPStatus.OK) + + def test_overview_view(self): + url = reverse('admin:finance_statementsubmitted_overview', + args=(self.statement.pk,)) + c = self._login('superuser') + response = c.get(url) + self.assertEqual(response.status_code, HTTPStatus.OK) + self.assertContains(response, _('View submitted statement')) + + def test_overview_view_statement_not_found(self): + """Test overview_view with statement that can't be found in StatementSubmitted queryset""" + # When trying to access an unsubmitted statement via StatementSubmitted admin, + # the decorator will fail to find it and show "Statement not found" + self.statement.submitted = False + self.statement.save() + + url = reverse('admin:finance_statementsubmitted_overview', args=(self.statement.pk,)) + c = self._login('superuser') + response = c.get(url, follow=True) + self.assertEqual(response.status_code, HTTPStatus.OK) + messages = list(get_messages(response.wsgi_request)) + expected_text = str(_("Statement not found.")) + self.assertTrue(any(expected_text in str(msg) for msg in messages)) + + def test_overview_view_transaction_execution_confirm(self): + """Test overview_view transaction execution confirm""" + # Set up statement to be valid for confirmation + self.transaction.ledger = self.ledger + self.transaction.save() + + # Create a bill that matches the transaction amount to make it valid + self._create_matching_bill() + + url = reverse('admin:finance_statementsubmitted_overview', args=(self.statement.pk,)) + c = self._login('superuser') + response = c.post(url, follow=True, data={'transaction_execution_confirm': ''}) + self.assertEqual(response.status_code, HTTPStatus.OK) + success_text = _("Successfully confirmed %(name)s. I hope you executed the associated transactions, I wont remind you again.") % {'name': str(self.statement)} + self.assertContains(response, success_text) + self.statement.refresh_from_db() + self.assertTrue(self.statement.confirmed) + + def test_overview_view_transaction_execution_confirm_and_send(self): + """Test overview_view transaction execution confirm and send""" + # Set up statement to be valid for confirmation + self.transaction.ledger = self.ledger + self.transaction.save() + + # Create a bill that matches the transaction amount to make it valid + self._create_matching_bill() + + url = reverse('admin:finance_statementsubmitted_overview', args=(self.statement.pk,)) + c = self._login('superuser') + response = c.post(url, follow=True, data={'transaction_execution_confirm_and_send': ''}) + self.assertEqual(response.status_code, HTTPStatus.OK) + success_text = _("Successfully sent receipt to the office.") + self.assertContains(response, success_text) + + def test_overview_view_confirm_valid(self): + """Test overview_view confirm with valid statement""" + # Create a statement with valid configuration + # Set up transaction with ledger to make it valid + self.transaction.ledger = self.ledger + self.transaction.save() + + # Create a bill that matches the transaction amount to make total valid + self._create_matching_bill() + + url = reverse('admin:finance_statementsubmitted_overview', + args=(self.statement.pk,)) + c = self._login('superuser') + response = c.post(url, data={'confirm': ''}) + self.assertEqual(response.status_code, HTTPStatus.OK) + self.assertContains(response, _('Statement confirmed')) + + def test_overview_view_confirm_non_matching_transactions(self): + """Test overview_view confirm with non-matching transactions""" + # Create a bill that doesn't match the transaction + self._create_non_matching_bill() + + url = reverse('admin:finance_statementsubmitted_overview', + args=(self.statement.pk,)) + c = self._login('superuser') + response = c.post(url, follow=True, data={'confirm': ''}) + self.assertEqual(response.status_code, HTTPStatus.OK) + error_text = _("Transactions do not match the covered expenses. Please correct the mistakes listed below.") + self.assertContains(response, error_text) + + def test_overview_view_confirm_missing_ledger(self): + """Test overview_view confirm with missing ledger""" + # Ensure transaction has no ledger (ledger=None) + self.transaction.ledger = None + self.transaction.save() + + # Create a bill that matches the transaction amount to pass the first check + self._create_matching_bill() + + url = reverse('admin:finance_statementsubmitted_overview', + args=(self.statement.pk,)) + c = self._login('superuser') + response = c.post(url, follow=True, data={'confirm': ''}) + self.assertEqual(response.status_code, HTTPStatus.OK) + # Check the Django messages for the error + messages = list(get_messages(response.wsgi_request)) + expected_text = str(_("Some transactions have no ledger configured. Please fill in the gaps.")) + self.assertTrue(any(expected_text in str(msg) for msg in messages)) + + def test_overview_view_confirm_invalid_allowance_to(self): + """Test overview_view confirm with invalid allowance""" + # Create excursion and set up invalid allowance configuration + self.statement.excursion = self.excursion + self.statement.save() + + # Add allowance recipient who is not a youth leader for this excursion + self.statement_no_trans_success.allowance_to.add(self.other_member) + + # Generate required transactions + self.statement_no_trans_success.generate_transactions() + for trans in self.statement_no_trans_success.transaction_set.all(): + trans.ledger = self.ledger + trans.save() + + # Check validity obstruction is allowances + self.assertEqual(self.statement_no_trans_success.validity, Statement.INVALID_ALLOWANCE_TO) + + url = reverse('admin:finance_statementsubmitted_overview', + args=(self.statement_no_trans_success.pk,)) + c = self._login('superuser') + response = c.post(url, follow=True, data={'confirm': ''}) + self.assertEqual(response.status_code, HTTPStatus.OK) + # Check the Django messages for the error + messages = list(get_messages(response.wsgi_request)) + expected_text = str(_("The configured recipients for the allowance don't match the regulations. Please correct this on the excursion.")) + self.assertTrue(any(expected_text in str(msg) for msg in messages)) + + def test_overview_view_reject(self): + """Test overview_view reject statement""" + url = reverse('admin:finance_statementsubmitted_overview', args=(self.statement.pk,)) + c = self._login('superuser') + response = c.post(url, follow=True, data={'reject': ''}) + self.assertEqual(response.status_code, HTTPStatus.OK) + success_text = _("Successfully rejected %(name)s. The requestor can reapply, when needed.") %\ + {'name': str(self.statement)} + self.assertContains(response, success_text) + + # Verify statement was rejected + self.statement.refresh_from_db() + self.assertFalse(self.statement.submitted) + + def test_overview_view_generate_transactions_existing(self): + """Test overview_view generate transactions with existing transactions""" + # Ensure there's already a transaction + self.assertTrue(self.statement.transaction_set.count() > 0) + + url = reverse('admin:finance_statementsubmitted_overview', args=(self.statement.pk,)) + c = self._login('superuser') + response = c.post(url, follow=True, data={'generate_transactions': ''}) + self.assertEqual(response.status_code, HTTPStatus.OK) + error_text = _("%(name)s already has transactions. Please delete them first, if you want to generate new ones") % {'name': str(self.statement)} + self.assertContains(response, error_text) + + def test_overview_view_generate_transactions_success(self): + """Test overview_view generate transactions successfully""" + url = reverse('admin:finance_statementsubmitted_overview', + args=(self.statement_no_trans_success.pk,)) + c = self._login('superuser') + response = c.post(url, follow=True, data={'generate_transactions': ''}) + self.assertEqual(response.status_code, HTTPStatus.OK) + success_text = _("Successfully generated transactions for %(name)s") %\ + {'name': str(self.statement_no_trans_success)} + self.assertContains(response, success_text) + + def test_overview_view_generate_transactions_error(self): + """Test overview_view generate transactions with error""" + url = reverse('admin:finance_statementsubmitted_overview', + args=(self.statement_no_trans_error.pk,)) + c = self._login('superuser') + response = c.post(url, follow=True, data={'generate_transactions': ''}) + self.assertEqual(response.status_code, HTTPStatus.OK) + messages = list(get_messages(response.wsgi_request)) + expected_text = str(_("Error while generating transactions for %(name)s. Do all bills have a payer and, if this statement is attached to an excursion, was a person selected that receives the subsidies?") %\ + {'name': str(self.statement_no_trans_error)}) + self.assertTrue(any(expected_text in str(msg) for msg in messages)) + def test_reduce_transactions_view(self): - """Test reduce_transactions_view logic""" - # Test GET parameters - request = self.factory.get('/', {'redirectTo': '/admin/'}) - self.assertIn('redirectTo', request.GET) - self.assertEqual(request.GET['redirectTo'], '/admin/') + url = reverse('admin:finance_statementsubmitted_reduce_transactions', + args=(self.statement.pk,)) + c = self._login('superuser') + response = c.get(url, data={'redirectTo': reverse('admin:finance_statementsubmitted_changelist')}, + follow=True) + self.assertContains(response, + _("Successfully reduced transactions for %(name)s.") %\ + {'name': str(self.statement)}) -class StatementConfirmedAdminTestCase(TestCase): +class StatementConfirmedAdminTestCase(AdminTestCase): """Test cases for StatementConfirmedAdmin""" def setUp(self): - self.site = AdminSite() - self.factory = RequestFactory() - self.admin = StatementConfirmedAdmin(StatementConfirmed, self.site) - - # Register the admin with the site to enable URL resolution - self.site.register(StatementConfirmed, StatementConfirmedAdmin) + super().setUp(model=StatementConfirmed, admin=StatementConfirmedAdmin) self.user = User.objects.create_user('testuser', 'test@example.com', 'pass') self.member = Member.objects.create( @@ -175,6 +517,37 @@ class StatementConfirmedAdminTestCase(TestCase): # StatementConfirmed is a proxy model, so we can get it from the base statement self.statement = StatementConfirmed.objects.get(pk=base_statement.pk) + # Create an unconfirmed statement for testing + self.unconfirmed_statement = Statement.objects.create( + short_description='Unconfirmed Statement', + explanation='Test explanation', + submitted=True, + confirmed=False, + night_cost=25 + ) + + # Create excursion for testing + self.excursion = Freizeit.objects.create( + name='Test Excursion', + kilometers_traveled=100, + tour_type=GEMEINSCHAFTS_TOUR, + tour_approach=MUSKELKRAFT_ANREISE, + difficulty=1 + ) + + # Create confirmed statement with excursion + confirmed_with_excursion_base = Statement.objects.create( + short_description='Confirmed with Excursion', + explanation='Test explanation', + submitted=True, + confirmed=True, + confirmed_by=self.member, + confirmed_date=timezone.now(), + excursion=self.excursion, + night_cost=25 + ) + self.statement_with_excursion = StatementConfirmed.objects.get(pk=confirmed_with_excursion_base.pk) + def _add_session_to_request(self, request): """Add session to request""" middleware = SessionMiddleware(lambda req: None) @@ -205,39 +578,22 @@ class StatementConfirmedAdminTestCase(TestCase): def test_unconfirm_view_not_confirmed_statement(self): """Test unconfirm_view with statement that is not confirmed""" - # Add special permission for unconfirm - unconfirm_perm = Permission.objects.get(codename='may_manage_confirmed_statements') - self.finance_user.user_permissions.add(unconfirm_perm) - # Create request for unconfirmed statement request = self.factory.get('/') request.user = self.finance_user self._add_session_to_request(request) - # Create an unconfirmed statement for this test - unconfirmed_base = Statement.objects.create( - short_description='Unconfirmed Statement', - explanation='Test explanation', - night_cost=25 - ) - # This won't be accessible via StatementConfirmed since it's not confirmed - unconfirmed_statement = unconfirmed_base - # Test with unconfirmed statement (should trigger error path) - self.assertFalse(unconfirmed_statement.confirmed) + self.assertFalse(self.unconfirmed_statement.confirmed) # Call unconfirm_view - this should go through error path - response = self.admin.unconfirm_view(request, unconfirmed_statement.pk) + response = self.admin.unconfirm_view(request, self.unconfirmed_statement.pk) # Should redirect due to not confirmed error self.assertEqual(response.status_code, 302) def test_unconfirm_view_post_unconfirm_action(self): """Test unconfirm_view POST request with 'unconfirm' action""" - # Add special permission for unconfirm - unconfirm_perm = Permission.objects.get(codename='may_manage_confirmed_statements') - self.finance_user.user_permissions.add(unconfirm_perm) - # Create POST request with unconfirm action request = self.factory.post('/', {'unconfirm': 'true'}) request.user = self.finance_user @@ -261,10 +617,6 @@ class StatementConfirmedAdminTestCase(TestCase): def test_unconfirm_view_get_render_template(self): """Test unconfirm_view GET request rendering template""" - # Add special permission for unconfirm - unconfirm_perm = Permission.objects.get(codename='may_manage_confirmed_statements') - self.finance_user.user_permissions.add(unconfirm_perm) - # Create GET request (no POST data) request = self.factory.get('/') request.user = self.finance_user @@ -283,6 +635,30 @@ class StatementConfirmedAdminTestCase(TestCase): self.assertIn(str(_('Unconfirm statement')).encode('utf-8'), response.content) self.assertIn(self.statement.short_description.encode(), response.content) + def test_statement_summary_view_insufficient_permission(self): + url = reverse('admin:finance_statementconfirmed_summary', + args=(self.statement_with_excursion.pk,)) + c = self._login('standard') + response = c.get(url, follow=True) + self.assertEqual(response.status_code, HTTPStatus.FORBIDDEN) + + def test_statement_summary_view_unconfirmed(self): + url = reverse('admin:finance_statementconfirmed_summary', + args=(self.unconfirmed_statement.pk,)) + c = self._login('superuser') + response = c.get(url, follow=True) + self.assertEqual(response.status_code, HTTPStatus.OK) + self.assertContains(response, _('Statement not found.')) + + def test_statement_summary_view_confirmed_with_excursion(self): + """Test statement_summary_view when statement is confirmed with excursion""" + url = reverse('admin:finance_statementconfirmed_summary', + args=(self.statement_with_excursion.pk,)) + c = self._login('superuser') + response = c.get(url, follow=True) + self.assertEqual(response.status_code, HTTPStatus.OK) + self.assertEqual(response.headers['Content-Type'], 'application/pdf') + class TransactionAdminTestCase(TestCase): """Test cases for TransactionAdmin""" From 187e4ebf542cb30b0d61635d10e58d2068748d91 Mon Sep 17 00:00:00 2001 From: Christian Merten Date: Sun, 17 Aug 2025 13:37:28 +0200 Subject: [PATCH 09/25] chore(mailer/tests): add model tests --- jdav_web/mailer/tests.py | 3 - jdav_web/mailer/tests/__init__.py | 2 + jdav_web/mailer/tests/admin.py | 0 jdav_web/mailer/tests/models.py | 271 ++++++++++++++++++++++++++++++ 4 files changed, 273 insertions(+), 3 deletions(-) delete mode 100644 jdav_web/mailer/tests.py create mode 100644 jdav_web/mailer/tests/__init__.py create mode 100644 jdav_web/mailer/tests/admin.py create mode 100644 jdav_web/mailer/tests/models.py diff --git a/jdav_web/mailer/tests.py b/jdav_web/mailer/tests.py deleted file mode 100644 index 7ce503c..0000000 --- a/jdav_web/mailer/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/jdav_web/mailer/tests/__init__.py b/jdav_web/mailer/tests/__init__.py new file mode 100644 index 0000000..012e5e5 --- /dev/null +++ b/jdav_web/mailer/tests/__init__.py @@ -0,0 +1,2 @@ +from .models import * +from .admin import * diff --git a/jdav_web/mailer/tests/admin.py b/jdav_web/mailer/tests/admin.py new file mode 100644 index 0000000..e69de29 diff --git a/jdav_web/mailer/tests/models.py b/jdav_web/mailer/tests/models.py new file mode 100644 index 0000000..8e6156c --- /dev/null +++ b/jdav_web/mailer/tests/models.py @@ -0,0 +1,271 @@ +from unittest import skip, mock +from django.test import TestCase +from django.conf import settings +from django.utils import timezone +from django.core.exceptions import ValidationError +from django.utils.translation import gettext as _ +from django.core.files.uploadedfile import SimpleUploadedFile +from members.models import Member, Group, DIVERSE, Freizeit, MemberNoteList, GEMEINSCHAFTS_TOUR, MUSKELKRAFT_ANREISE +from mailer.models import EmailAddress, EmailAddressForm, Message, MessageForm, Attachment +from mailer.mailutils import SENT, NOT_SENT, PARTLY_SENT + + +class BasicMailerTestCase(TestCase): + def setUp(self): + self.mygroup = Group.objects.create(name="My Group") + self.fritz = Member.objects.create(prename="Fritz", lastname="Wulter", birth_date=timezone.now().date(), + email='fritz@foo.com', gender=DIVERSE) + self.fritz.group.add(self.mygroup) + self.fritz.save() + + self.paul = Member.objects.create(prename="Paul", lastname="Wulter", birth_date=timezone.now().date(), + email='paul@foo.com', gender=DIVERSE) + + self.em = EmailAddress.objects.create(name='foobar') + self.em.to_groups.add(self.mygroup) + self.em.to_members.add(self.paul) + + +class EmailAddressTestCase(BasicMailerTestCase): + def test_email(self): + self.assertEqual(self.em.email, f"foobar@{settings.DOMAIN}") + + def test_str(self): + self.assertEqual(self.em.email, str(self.em)) + + def test_forwards(self): + self.assertEqual(self.em.forwards, {'fritz@foo.com', 'paul@foo.com'}) + + +class EmailAddressFormTestCase(BasicMailerTestCase): + def test_clean(self): + # instantiate form with only name field set + form = EmailAddressForm(data={'name': 'bar'}) + # validate the form - this should fail due to missing required recipients + self.assertFalse(form.is_valid()) + + +class MessageFormTestCase(BasicMailerTestCase): + def test_clean(self): + # instantiate form with only subject and content fields set + form = MessageForm(data={'subject': 'Test Subject', 'content': 'Test content'}) + # validate the form - this should fail due to missing required recipients + self.assertFalse(form.is_valid()) + + +class MessageTestCase(BasicMailerTestCase): + def setUp(self): + super().setUp() + self.message = Message.objects.create( + subject='Test Message', + content='This is a test message' + ) + self.freizeit = Freizeit.objects.create( + name='Test Freizeit', + kilometers_traveled=120, + tour_type=GEMEINSCHAFTS_TOUR, + tour_approach=MUSKELKRAFT_ANREISE, + difficulty=1 + ) + self.notelist = MemberNoteList.objects.create( + title='Test Note List' + ) + + # Set up message with multiple recipient types + self.message.to_groups.add(self.mygroup) + self.message.to_freizeit = self.freizeit + self.message.to_notelist = self.notelist + self.message.to_members.add(self.fritz) + self.message.save() + + # Create a sender member for submit tests + self.sender = Member.objects.create( + prename='Sender', + lastname='Test', + birth_date=timezone.now().date(), + email='sender@test.com', + gender=DIVERSE + ) + + def test_str(self): + self.assertEqual(str(self.message), 'Test Message') + + def test_get_recipients(self): + recipients = self.message.get_recipients() + self.assertIn('My Group', recipients) + self.assertIn('Test Freizeit', recipients) + self.assertIn('Test Note List', recipients) + self.assertIn('Fritz Wulter', recipients) + + def test_get_recipients_with_many_members(self): + # Add additional members to test the "Some other members" case + for i in range(3): + member = Member.objects.create( + prename=f'Member{i}', + lastname='Test', + birth_date=timezone.now().date(), + email=f'member{i}@test.com', + gender=DIVERSE + ) + self.message.to_members.add(member) + + recipients = self.message.get_recipients() + self.assertIn(_('Some other members'), recipients) + + @mock.patch('mailer.models.send') + def test_submit_successful(self, mock_send): + # Mock successful email sending + mock_send.return_value = SENT + + # Test submit method + result = self.message.submit(sender=self.sender) + + # Verify the message was marked as sent + self.message.refresh_from_db() + self.assertTrue(self.message.sent) + self.assertEqual(result, SENT) + + # Verify send was called + self.assertTrue(mock_send.called) + + @mock.patch('mailer.models.send') + def test_submit_failed(self, mock_send): + # Mock failed email sending + mock_send.return_value = NOT_SENT + + # Test submit method + result = self.message.submit(sender=self.sender) + + # Verify the message was not marked as sent + self.message.refresh_from_db() + self.assertFalse(self.message.sent) + # Note: The submit method always returns SENT due to line 190 in the code + self.assertEqual(result, SENT) + + @mock.patch('mailer.models.send') + def test_submit_without_sender(self, mock_send): + # Mock successful email sending + mock_send.return_value = SENT + + # Test submit method without sender + result = self.message.submit() + + # Verify the message was marked as sent + self.message.refresh_from_db() + self.assertTrue(self.message.sent) + self.assertEqual(result, SENT) + + @mock.patch('mailer.models.send') + def test_submit_subject_cleaning(self, mock_send): + # Mock successful email sending + mock_send.return_value = SENT + + # Create message with underscores in subject + message_with_underscores = Message.objects.create( + subject='Test_Message_With_Underscores', + content='Test content' + ) + message_with_underscores.to_members.add(self.fritz) + + # Test submit method + result = message_with_underscores.submit() + + # Verify underscores were removed from subject + message_with_underscores.refresh_from_db() + self.assertEqual(message_with_underscores.subject, 'Test Message With Underscores') + + @mock.patch('mailer.models.send') + def test_submit_exception_handling(self, mock_send): + # Mock an exception during email sending + mock_send.side_effect = Exception("Email sending failed") + + # Test submit method + result = self.message.submit(sender=self.sender) + + # Verify the message was not marked as sent + self.message.refresh_from_db() + self.assertFalse(self.message.sent) + # When exception occurs, it should return NOT_SENT + self.assertEqual(result, NOT_SENT) + + @mock.patch('mailer.models.send') + @mock.patch('django.conf.settings.SEND_FROM_ASSOCIATION_EMAIL', False) + def test_submit_with_sender_no_association_email(self, mock_send): + # Mock successful email sending + mock_send.return_value = PARTLY_SENT + + # Test submit method with sender but SEND_FROM_ASSOCIATION_EMAIL disabled + result = self.message.submit(sender=self.sender) + + # Verify the message was marked as sent + self.message.refresh_from_db() + self.assertTrue(self.message.sent) + self.assertEqual(result, SENT) + + @mock.patch('mailer.models.send') + @mock.patch('django.conf.settings.SEND_FROM_ASSOCIATION_EMAIL', False) + def test_submit_with_reply_to_logic(self, mock_send): + # Mock successful email sending + mock_send.return_value = SENT + + # Create a sender with internal email capability + sender_with_internal = Member.objects.create( + prename='Internal', + lastname='Sender', + birth_date=timezone.now().date(), + email='internal@test.com', + gender=DIVERSE + ) + + # Mock has_internal_email to return True + with mock.patch.object(sender_with_internal, 'has_internal_email', return_value=True): + # Test submit method + result = self.message.submit(sender=sender_with_internal) + + # Verify the message was marked as sent + self.message.refresh_from_db() + self.assertTrue(self.message.sent) + self.assertEqual(result, SENT) + + @mock.patch('mailer.models.send') + @mock.patch('os.remove') + def test_submit_with_attachments(self, mock_os_remove, mock_send): + # Mock successful email sending + mock_send.return_value = SENT + + # Create an attachment with a file + test_file = SimpleUploadedFile("test_file.pdf", b"file_content", content_type="application/pdf") + attachment = Attachment.objects.create(msg=self.message, f=test_file) + + # Test submit method + result = self.message.submit() + + # Verify the message was marked as sent + self.message.refresh_from_db() + self.assertTrue(self.message.sent) + self.assertEqual(result, SENT) + + # Verify file removal was attempted (the path will be the actual file path) + mock_os_remove.assert_called() + # Attachment should be deleted + with self.assertRaises(Attachment.DoesNotExist): + attachment.refresh_from_db() + + +class AttachmentTestCase(BasicMailerTestCase): + def setUp(self): + super().setUp() + self.message = Message.objects.create( + subject='Test Message', + content='Test content' + ) + self.attachment = Attachment.objects.create(msg=self.message) + + def test_str_with_file(self): + # Simulate a file name + self.attachment.f.name = 'attachments/test_document.pdf' + self.assertEqual(str(self.attachment), 'test_document.pdf') + + @skip('Fails with TypeError: __str__ returns a lazy translation object, but must return a string.') + def test_str_without_file(self): + self.assertEqual(str(self.attachment), _('Empty')) From c7c64139a4e10c83285c6c8e368f4b14413cf775 Mon Sep 17 00:00:00 2001 From: Christian Merten Date: Sun, 17 Aug 2025 14:17:57 +0200 Subject: [PATCH 10/25] chore(mailer/tests): unsubscribe view tests --- jdav_web/mailer/tests/__init__.py | 1 + jdav_web/mailer/tests/models.py | 17 +------- jdav_web/mailer/tests/utils.py | 27 +++++++++++++ jdav_web/mailer/tests/views.py | 65 +++++++++++++++++++++++++++++++ 4 files changed, 94 insertions(+), 16 deletions(-) create mode 100644 jdav_web/mailer/tests/utils.py create mode 100644 jdav_web/mailer/tests/views.py diff --git a/jdav_web/mailer/tests/__init__.py b/jdav_web/mailer/tests/__init__.py index 012e5e5..a80e178 100644 --- a/jdav_web/mailer/tests/__init__.py +++ b/jdav_web/mailer/tests/__init__.py @@ -1,2 +1,3 @@ from .models import * from .admin import * +from .views import * diff --git a/jdav_web/mailer/tests/models.py b/jdav_web/mailer/tests/models.py index 8e6156c..ded8fad 100644 --- a/jdav_web/mailer/tests/models.py +++ b/jdav_web/mailer/tests/models.py @@ -8,22 +8,7 @@ from django.core.files.uploadedfile import SimpleUploadedFile from members.models import Member, Group, DIVERSE, Freizeit, MemberNoteList, GEMEINSCHAFTS_TOUR, MUSKELKRAFT_ANREISE from mailer.models import EmailAddress, EmailAddressForm, Message, MessageForm, Attachment from mailer.mailutils import SENT, NOT_SENT, PARTLY_SENT - - -class BasicMailerTestCase(TestCase): - def setUp(self): - self.mygroup = Group.objects.create(name="My Group") - self.fritz = Member.objects.create(prename="Fritz", lastname="Wulter", birth_date=timezone.now().date(), - email='fritz@foo.com', gender=DIVERSE) - self.fritz.group.add(self.mygroup) - self.fritz.save() - - self.paul = Member.objects.create(prename="Paul", lastname="Wulter", birth_date=timezone.now().date(), - email='paul@foo.com', gender=DIVERSE) - - self.em = EmailAddress.objects.create(name='foobar') - self.em.to_groups.add(self.mygroup) - self.em.to_members.add(self.paul) +from .utils import BasicMailerTestCase class EmailAddressTestCase(BasicMailerTestCase): diff --git a/jdav_web/mailer/tests/utils.py b/jdav_web/mailer/tests/utils.py new file mode 100644 index 0000000..3ad3e50 --- /dev/null +++ b/jdav_web/mailer/tests/utils.py @@ -0,0 +1,27 @@ +from unittest import skip, mock +from django.test import TestCase +from django.conf import settings +from django.utils import timezone +from django.core.exceptions import ValidationError +from django.utils.translation import gettext as _ +from django.core.files.uploadedfile import SimpleUploadedFile +from members.models import Member, Group, DIVERSE, Freizeit, MemberNoteList, GEMEINSCHAFTS_TOUR, MUSKELKRAFT_ANREISE +from mailer.models import EmailAddress, EmailAddressForm, Message, MessageForm, Attachment +from mailer.mailutils import SENT, NOT_SENT, PARTLY_SENT + + +class BasicMailerTestCase(TestCase): + def setUp(self): + self.mygroup = Group.objects.create(name="My Group") + self.fritz = Member.objects.create(prename="Fritz", lastname="Wulter", birth_date=timezone.now().date(), + email='fritz@foo.com', gender=DIVERSE) + self.fritz.group.add(self.mygroup) + self.fritz.save() + self.fritz.generate_key() + + self.paul = Member.objects.create(prename="Paul", lastname="Wulter", birth_date=timezone.now().date(), + email='paul@foo.com', gender=DIVERSE) + + self.em = EmailAddress.objects.create(name='foobar') + self.em.to_groups.add(self.mygroup) + self.em.to_members.add(self.paul) diff --git a/jdav_web/mailer/tests/views.py b/jdav_web/mailer/tests/views.py new file mode 100644 index 0000000..fc00b54 --- /dev/null +++ b/jdav_web/mailer/tests/views.py @@ -0,0 +1,65 @@ +from unittest import skip, mock +from http import HTTPStatus +from django.urls import reverse +from django.test import TestCase +from django.conf import settings +from django.utils import timezone +from django.core.exceptions import ValidationError +from django.utils.translation import gettext as _ +from django.core.files.uploadedfile import SimpleUploadedFile +from members.models import Member, Group, DIVERSE, Freizeit, MemberNoteList, GEMEINSCHAFTS_TOUR, MUSKELKRAFT_ANREISE +from mailer.models import EmailAddress, EmailAddressForm, Message, MessageForm, Attachment +from mailer.mailutils import SENT, NOT_SENT, PARTLY_SENT +from .utils import BasicMailerTestCase + + +class IndexTestCase(BasicMailerTestCase): + def test_index(self): + url = reverse('mailer:index') + response = self.client.get(url) + self.assertEqual(response.status_code, HTTPStatus.FOUND) + + +class UnsubscribeTestCase(BasicMailerTestCase): + def test_unsubscribe(self): + url = reverse('mailer:unsubscribe') + response = self.client.get(url) + self.assertEqual(response.status_code, HTTPStatus.OK) + self.assertContains(response, _("Here you can unsubscribe from the newsletter")) + + def test_unsubscribe_key_invalid(self): + url = reverse('mailer:unsubscribe') + + # invalid key + response = self.client.get(url, data={'key': 'invalid'}) + self.assertEqual(response.status_code, HTTPStatus.OK) + self.assertContains(response, _("Can't verify this link. Try again!")) + + # expired key + self.fritz.unsubscribe_expire = timezone.now() + self.fritz.save() + response = self.client.get(url, data={'key': self.fritz.unsubscribe_key}) + self.assertEqual(response.status_code, HTTPStatus.OK) + self.assertContains(response, _("Can't verify this link. Try again!")) + + def test_unsubscribe_key(self): + url = reverse('mailer:unsubscribe') + response = self.client.get(url, data={'key': self.fritz.unsubscribe_key}) + self.assertEqual(response.status_code, HTTPStatus.OK) + self.assertContains(response, _("Successfully unsubscribed from the newsletter for ")) + + def test_unsubscribe_post_incomplete(self): + url = reverse('mailer:unsubscribe') + response = self.client.post(url, data={'post': True}) + self.assertEqual(response.status_code, HTTPStatus.OK) + self.assertContains(response, _("Please fill in every field")) + + response = self.client.post(url, data={'post': True, 'email': 'foobar@notexisting.com'}) + self.assertEqual(response.status_code, HTTPStatus.OK) + self.assertContains(response, _("Please fill in every field")) + + def test_unsubscribe_post(self): + url = reverse('mailer:unsubscribe') + response = self.client.post(url, data={'post': True, 'email': self.fritz.email}) + self.assertEqual(response.status_code, HTTPStatus.OK) + self.assertContains(response, _("Sent confirmation mail to")) From 88521def1a09a9a75286072dca91ef2a2a9293fb Mon Sep 17 00:00:00 2001 From: Christian Merten Date: Sun, 17 Aug 2025 14:25:59 +0200 Subject: [PATCH 11/25] chore(mailer): remove unused subscribe views --- jdav_web/mailer/urls.py | 1 - jdav_web/mailer/views.py | 47 ---------------------------------------- 2 files changed, 48 deletions(-) diff --git a/jdav_web/mailer/urls.py b/jdav_web/mailer/urls.py index 91f16e6..a682f6d 100644 --- a/jdav_web/mailer/urls.py +++ b/jdav_web/mailer/urls.py @@ -5,6 +5,5 @@ from . import views app_name = "mailer" urlpatterns = [ re_path(r'^$', views.index, name='index'), - # url(r'^subscribe', views.subscribe, name='subscribe'), re_path(r'^unsubscribe', views.unsubscribe, name='unsubscribe'), ] diff --git a/jdav_web/mailer/views.py b/jdav_web/mailer/views.py index 863ef1d..d529f3e 100644 --- a/jdav_web/mailer/views.py +++ b/jdav_web/mailer/views.py @@ -53,52 +53,5 @@ def unsubscribe(request): return render_confirmation_sent(request, email) -def render_subscribe(request, error_message=""): - date_input = forms.DateInput(attrs={'required': True, - 'class': 'datepicker', - 'name': 'birthdate'}) - date_field = date_input.render(_("Birthdate"), "") - context = {'date_field': date_field} - if error_message: - context['error_message'] = error_message - return render(request, 'mailer/subscribe.html', context) - - def render_confirmation_sent(request, email): return render(request, 'mailer/confirmation_sent.html', {'email': email}) - - -def subscribe(request): - try: - request.POST['post'] - try: - print("trying to subscribe") - prename = request.POST['prename'] - lastname = request.POST['lastname'] - email = request.POST['email'] - print("email", email) - birth_date = request.POST['birthdate'] - print("birthdate", birth_date) - except KeyError: - return subscribe(request, _("Please fill in every field!")) - else: - # TODO: check whether member exists - exists = Member.objects.filter(prename=prename, - lastname=lastname) - if len(exists) > 0: - return render_subscribe(request, - error_message=_("Member " - "already exists")) - member = Member(prename=prename, - lastname=lastname, - email=email, - birth_date=birth_date, - gets_newsletter=True) - member.save() - return subscribed(request) - except KeyError: - return render_subscribe(request) - - -def subscribed(request): - return render(request, 'mailer/subscribed.html') From b9d112e047a2ede1bac8760bbc2dc8a5bc894b35 Mon Sep 17 00:00:00 2001 From: Christian Merten Date: Sun, 17 Aug 2025 18:25:02 +0200 Subject: [PATCH 12/25] chore(members/tests): more admin tests --- jdav_web/members/admin.py | 5 +- jdav_web/members/tests/basic.py | 187 +++++++++++++++++++++++++++++++- 2 files changed, 185 insertions(+), 7 deletions(-) diff --git a/jdav_web/members/admin.py b/jdav_web/members/admin.py index c33caa8..9cf41bf 100644 --- a/jdav_web/members/admin.py +++ b/jdav_web/members/admin.py @@ -331,7 +331,8 @@ class MemberAdmin(CommonAdminMixin, admin.ModelAdmin): return request.user.has_perm('%s.%s' % (self.opts.app_label, 'may_invite_as_user')) def invite_as_user_action(self, request, queryset): - if not request.user.has_perm('members.may_invite_as_user'): + if not request.user.has_perm('members.may_invite_as_user'): # pragma: no cover + # this should be unreachable, because of allowed_permissions attribute messages.error(request, _('Permission denied.')) return HttpResponseRedirect(reverse('admin:%s_%s_changelist' % (self.opts.app_label, self.opts.model_name))) if "apply" in request.POST: @@ -846,7 +847,7 @@ class GroupAdmin(CommonAdminMixin, admin.ModelAdmin): return update_wrapper(wrapper, view) custom_urls = [ - path('action/', self.action_view, name='members_group_action'), + path('action/', wrap(self.action_view), name='members_group_action'), ] return custom_urls + urls diff --git a/jdav_web/members/tests/basic.py b/jdav_web/members/tests/basic.py index 20295a1..c4db72a 100644 --- a/jdav_web/members/tests/basic.py +++ b/jdav_web/members/tests/basic.py @@ -23,7 +23,8 @@ from members.models import Member, Group, PermissionMember, PermissionGroup, Fre TrainingCategory, Person from members.admin import MemberWaitingListAdmin, MemberAdmin, FreizeitAdmin, MemberNoteListAdmin,\ MemberUnconfirmedAdmin, RegistrationFilter, FilteredMemberFieldMixin,\ - MemberAdminForm, StatementOnListForm, KlettertreffAdmin, GroupAdmin + MemberAdminForm, StatementOnListForm, KlettertreffAdmin, GroupAdmin,\ + InvitationToGroupAdmin, AgeFilter, InvitedToGroupFilter from members.pdf import fill_pdf_form, render_tex, media_path, serve_pdf, find_template, merge_pdfs from mailer.models import EmailAddress from finance.models import Statement, Bill @@ -312,7 +313,8 @@ class AdminTestCase(TestCase): paul = standard.member - self.staff = Group.objects.create(name='Jugendleiter') + self.em = EmailAddress.objects.create(name='foobar') + self.staff = Group.objects.create(name='Jugendleiter', contact_email=self.em) cool_kids = Group.objects.create(name='cool kids') super_kids = Group.objects.create(name='super kids') @@ -546,6 +548,16 @@ class MemberAdminTestCase(AdminTestCase): self.assertEqual(response.status_code, HTTPStatus.OK) self.assertContains(response, _("%(name)s already has login data.") % {'name': str(self.fritz)}) + def test_invite_as_user_action_insufficient_permission(self): + url = reverse('admin:members_member_changelist') + + # expect: confirmation view + c = self._login('trainer') + response = c.post(url, data={'action': 'invite_as_user_action', + '_selected_action': [self.fritz.pk]}, follow=True) + self.assertEqual(response.status_code, HTTPStatus.OK) + self.assertNotContains(response, _('Invite')) + def test_invite_as_user_action(self): qs = Member.objects.all() url = reverse('admin:members_member_changelist') @@ -597,6 +609,15 @@ class MemberAdminTestCase(AdminTestCase): self.fritz._activity_score = i * 10 - 1 self.assertTrue('img' in self.admin.activity_score(self.fritz)) + def test_unconfirm(self): + url = reverse('admin:members_member_changelist') + c = self._login('superuser') + response = c.post(url, data={'action': 'unconfirm', + '_selected_action': [self.fritz.pk]}, follow=True) + self.assertEqual(response.status_code, HTTPStatus.OK) + self.fritz.refresh_from_db() + self.assertFalse(self.fritz.confirmed) + class FreizeitTestCase(BasicMemberTestCase): def setUp(self): @@ -799,6 +820,11 @@ class FreizeitAdminTestCase(AdminTestCase, PDFActionMixin): goal_strategy='my strategy', not_bw_reason=LJPProposal.NOT_BW_ROOMS, excursion=self.ex2) + self.st_ljp = Statement.objects.create(night_cost=11, subsidy_to=fr, ljp_to=fr, + excursion=self.ex2) + self.bill_no_proof = Bill.objects.create(statement=self.st_ljp, short_description='bla', explanation='bli', + amount=42.69, costs_covered=True, paid_by=fr) + def test_changelist(self): c = self._login('superuser') @@ -948,6 +974,10 @@ class FreizeitAdminTestCase(AdminTestCase, PDFActionMixin): }) self.assertEqual(response.status_code, HTTPStatus.OK) + @skip('Throws `AttributeError`: `Freizeit.seminar_vbk` does not exist.') + def test_seminar_vbk(self): + self._test_pdf('seminar_vbk', self.ex.pk) + def test_crisis_intervention_list_post(self): self._test_pdf('crisis_intervention_list', self.ex.pk) self._test_pdf('crisis_intervention_list', self.ex.pk, username='standard', invalid=True) @@ -968,6 +998,24 @@ class FreizeitAdminTestCase(AdminTestCase, PDFActionMixin): self.assertEqual(response.status_code, HTTPStatus.OK) self.assertContains(response, _("No statement found. Please add a statement and then retry.")) + def test_finance_overview_invalid_post(self): + url = reverse('admin:members_freizeit_action', args=(self.ex2.pk,)) + c = self._login('superuser') + + # bill with missing proof + response = c.post(url, data={'finance_overview': '', 'apply': ''}, follow=True) + self.assertEqual(response.status_code, HTTPStatus.OK) + self.assertContains(response, + _("The excursion is configured to claim LJP contributions. In that case, for all bills, a proof must be uploaded. Please correct this and try again.")) + + # invalidate allowance_to + self.st_ljp.allowance_to.add(self.yl1) + + response = c.post(url, data={'finance_overview': '', 'apply': ''}, follow=True) + self.assertEqual(response.status_code, HTTPStatus.OK) + self.assertContains(response, + _("The configured recipients of the allowance don't match the regulations. Please correct this and try again.")) + def test_finance_overview_post(self): url = reverse('admin:members_freizeit_action', args=(self.ex.pk,)) c = self._login('superuser') @@ -1008,12 +1056,18 @@ class MemberNoteListAdminTestCase(AdminTestCase, PDFActionMixin): def test_wrong_action_membernotelist(self): return self._test_pdf('asdf', self.note.pk, invalid=True, model='membernotelist') + def test_change(self): + c = self._login('superuser') + + url = reverse('admin:members_membernotelist_change', args=(self.note.pk,)) + response = c.get(url) + self.assertEqual(response.status_code, 200, 'Response code is not 200.') + class MemberWaitingListAdminTestCase(AdminTestCase): def setUp(self): super().setUp(model=MemberWaitingList, admin=MemberWaitingListAdmin) self.waiter = MemberWaitingList.objects.create(**WAITER_DATA) - self.em = EmailAddress.objects.create(name='foobar') for i in range(10): day = random.randint(1, 28) month = random.randint(1, 12) @@ -1039,6 +1093,14 @@ class MemberWaitingListAdminTestCase(AdminTestCase): self.assertEqual(m.birth_date_delta, m.age(), msg='Queryset based age calculation differs from python based age calculation for birth date {birth_date} compared to {today}.'.format(birth_date=m.birth_date, today=today)) + def test_invite_view_invalid(self): + c = self._login('superuser') + url = reverse('admin:members_memberwaitinglist_invite', args=(12312,)) + + response = c.get(url, follow=True) + self.assertEqual(response.status_code, HTTPStatus.OK) + self.assertContains(response, _("A waiter with this ID does not exist.")) + def test_invite_view_post(self): c = self._login('standard') url = reverse('admin:members_memberwaitinglist_invite', args=(self.waiter.pk,)) @@ -1050,6 +1112,9 @@ class MemberWaitingListAdminTestCase(AdminTestCase): 'group': 424242}) self.assertEqual(response.status_code, HTTPStatus.FOUND) + self.staff.contact_email = None + self.staff.save() + response = c.post(url, data={'apply': '', 'group': self.staff.pk}) self.assertEqual(response.status_code, HTTPStatus.FOUND) @@ -1075,7 +1140,10 @@ class MemberWaitingListAdminTestCase(AdminTestCase): url = reverse('admin:members_memberwaitinglist_changelist') qs = MemberWaitingList.objects.all() response = c.post(url, data={'action': 'ask_for_registration_action', - '_selected_action': [qs[0].pk]}, follow=True) + '_selected_action': [qs[0].pk], + 'send': '', + 'text_template': '', + 'group': self.staff.pk}, follow=True) self.assertEqual(response.status_code, HTTPStatus.OK) def test_age(self): @@ -1092,6 +1160,19 @@ class MemberWaitingListAdminTestCase(AdminTestCase): '_selected_action': [q.pk for q in qs]}, follow=True) self.assertEqual(response.status_code, HTTPStatus.OK) + def test_request_mail_confirmation(self): + c = self._login('superuser') + url = reverse('admin:members_memberwaitinglist_changelist') + qs = MemberWaitingList.objects.all() + + response = c.post(url, data={'action': 'request_mail_confirmation', + '_selected_action': [q.pk for q in qs]}, follow=True) + self.assertEqual(response.status_code, HTTPStatus.OK) + + response = c.post(url, data={'action': 'request_required_mail_confirmation', + '_selected_action': [q.pk for q in qs]}, follow=True) + self.assertEqual(response.status_code, HTTPStatus.OK) + class MemberUnconfirmedAdminTestCase(AdminTestCase): def setUp(self): @@ -1100,6 +1181,20 @@ class MemberUnconfirmedAdminTestCase(AdminTestCase): for i in range(10): MemberUnconfirmedProxy.objects.create(**REGISTRATION_DATA, confirmed=False) + def test_get_queryset(self): + request = self.factory.get('/') + request.user = User.objects.get(username='superuser') + qs = self.admin.get_queryset(request) + self.assertQuerysetEqual(qs, MemberUnconfirmedProxy.objects.all(), ordered=False) + + request.user = User.objects.create(username='test', password='secret') + qs = self.admin.get_queryset(request) + self.assertQuerysetEqual(qs, MemberUnconfirmedProxy.objects.none(), ordered=False) + + request.user = User.objects.get(username='standard') + qs = self.admin.get_queryset(request) + self.assertQuerysetEqual(qs, MemberUnconfirmedProxy.objects.none(), ordered=False) + def test_demote_to_waiter(self): c = self._login('superuser') url = reverse('admin:members_memberunconfirmedproxy_demote', args=(self.reg.pk,)) @@ -1157,6 +1252,16 @@ class MemberUnconfirmedAdminTestCase(AdminTestCase): self.assertEqual(response.status_code, HTTPStatus.OK) self.assertContains(response, _("Successfully requested mail confirmation from selected registrations.")) + def test_request_required_mail_confirmation(self): + c = self._login('superuser') + url = reverse('admin:members_memberunconfirmedproxy_changelist') + qs = MemberUnconfirmedProxy.objects.all() + response = c.post(url, data={'action': 'request_required_mail_confirmation', + '_selected_action': [qs[0].pk]}, follow=True) + self.assertEqual(response.status_code, HTTPStatus.OK) + self.assertContains(response, + _("Successfully re-requested missing mail confirmations from selected registrations.")) + def test_changelist(self): c = self._login('standard') url = reverse('admin:members_memberunconfirmedproxy_changelist') @@ -1754,6 +1859,14 @@ class TestRegistrationFilterTestCase(AdminTestCase): fil = RegistrationFilter(None, {}, Member, self.admin) self.assertQuerysetEqual(fil.queryset(None, qs), qs, ordered=False) + def test_choices(self): + fil = RegistrationFilter(None, {'registration_complete': 'True'}, Member, self.admin) + request = RequestFactory().get("/", {}) + request.user = User.objects.get(username='superuser') + changelist = self.admin.get_changelist_instance(request) + choices = list(fil.choices(changelist)) + self.assertEqual(choices[0]['display'], _('Yes')) + @skip("Currently errors, because 'registration_complete' is not a field.") def test_queryset_filter(self): qs = Member.objects.all() @@ -1837,12 +1950,18 @@ class KlettertreffAdminTestCase(AdminTestCase): '_selected_action': [kl.pk for kl in qs]}, follow=True) self.assertEqual(response.status_code, HTTPStatus.OK) - # expect: success and filtered by group, this does not work + @skip('Members are not filtered by group, because group attribute is retrieved from GET data.') + def test_overview_filtered(self): + qs = Klettertreff.objects.all() + url = reverse('admin:members_klettertreff_changelist') + + # expect: success and filtered by group c = self._login('superuser') response = c.post(url, data={'action': 'overview', 'group__name': 'cool kids', '_selected_action': [kl.pk for kl in qs]}, follow=True) self.assertEqual(response.status_code, HTTPStatus.OK) + self.assertNotContains(response, 'Lulla') class GroupAdminTestCase(AdminTestCase): @@ -1856,6 +1975,16 @@ class GroupAdminTestCase(AdminTestCase): response = c.get(url) self.assertEqual(response.status_code, HTTPStatus.OK) + def test_group_overview(self): + url = reverse('admin:members_group_action') + c = self._login('standard') + response = c.post(url, data={'group_overview': ''}, follow=True) + self.assertEqual(response.status_code, HTTPStatus.FORBIDDEN) + + c = self._login('superuser') + response = c.post(url, data={'group_overview': ''}, follow=True) + self.assertEqual(response.status_code, HTTPStatus.OK) + class FilteredMemberFieldMixinTestCase(AdminTestCase): def setUp(self): @@ -2070,3 +2199,51 @@ class EmergencyContactTestCase(TestCase): def test_str(self): self.assertEqual(str(self.emergency_contact), str(self.member)) + + +class InvitationToGroupAdminTestCase(AdminTestCase): + def setUp(self): + super().setUp(model=InvitationToGroup, admin=InvitationToGroupAdmin) + + def test_has_add_permission(self): + self.assertFalse(self.admin.has_add_permission(None)) + + +class MemberWaitingListFilterTestCase(AdminTestCase): + def setUp(self): + super().setUp(model=MemberWaitingList, admin=MemberWaitingListAdmin) + self.waiter = MemberWaitingList.objects.create(**WAITER_DATA) + self.waiter.invite_to_group(self.staff) + + +class AgeFilterTestCase(MemberWaitingListFilterTestCase): + def test_queryset_no_value(self): + fil = AgeFilter(None, {}, MemberWaitingList, self.admin) + qs = MemberWaitingList.objects.all() + self.assertQuerysetEqual(fil.queryset(None, qs), qs, ordered=False) + + def test_queryset(self): + fil = AgeFilter(None, {'age': 12}, MemberWaitingList, self.admin) + request = self.factory.get('/') + request.user = User.objects.get(username='superuser') + qs = self.admin.get_queryset(request) + self.assertQuerysetEqual(fil.queryset(request, qs), + qs.filter(birth_date_delta=12), + ordered=False) + + +class InvitedToGroupFilterTestCase(MemberWaitingListFilterTestCase): + def test_queryset_no_value(self): + fil = InvitedToGroupFilter(None, {}, MemberWaitingList, self.admin) + qs = MemberWaitingList.objects.all() + self.assertQuerysetEqual(fil.queryset(None, qs), qs, ordered=False) + + def test_queryset(self): + fil = InvitedToGroupFilter(None, {'pending_group_invitation': self.staff.pk}, + MemberWaitingList, self.admin) + request = self.factory.get('/') + request.user = User.objects.get(username='superuser') + qs = self.admin.get_queryset(request) + self.assertQuerysetEqual(fil.queryset(request, qs).distinct(), + [self.waiter], + ordered=False) From 2b9fd2556b1d01adb521a7947bd7d1e1b3d9f64a Mon Sep 17 00:00:00 2001 From: Christian Merten Date: Sun, 17 Aug 2025 18:55:43 +0200 Subject: [PATCH 13/25] chore(members/models): remove unused function --- jdav_web/members/models.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/jdav_web/members/models.py b/jdav_web/members/models.py index 7531680..8a73f75 100644 --- a/jdav_web/members/models.py +++ b/jdav_web/members/models.py @@ -35,9 +35,6 @@ from utils import cvt_to_decimal, coming_midnight from dateutil.relativedelta import relativedelta from schwifty import IBAN -def generate_random_key(): - return uuid.uuid4().hex - GEMEINSCHAFTS_TOUR = MUSKELKRAFT_ANREISE = MALE = 0 FUEHRUNGS_TOUR = OEFFENTLICHE_ANREISE = FEMALE = 1 From 242eff1ffc243a4daedac53354c12ac8094bee16 Mon Sep 17 00:00:00 2001 From: Christian Merten Date: Sun, 17 Aug 2025 21:14:14 +0200 Subject: [PATCH 14/25] chore(members/tests): more model tests --- jdav_web/members/models.py | 20 +++++- jdav_web/members/tests/basic.py | 122 ++++++++++++++++++++++++++++++-- jdav_web/members/tests/utils.py | 4 +- 3 files changed, 137 insertions(+), 9 deletions(-) diff --git a/jdav_web/members/models.py b/jdav_web/members/models.py index 8a73f75..0d8fe64 100644 --- a/jdav_web/members/models.py +++ b/jdav_web/members/models.py @@ -141,7 +141,7 @@ class Group(models.Model): group_age = self.get_age_info() else: group_age = _("no information available") - + return settings.INVITE_TEXT.format(group_time=group_time, group_name=self.name, group_age=group_age, @@ -205,7 +205,8 @@ class Contact(CommonModel): for email_fd, confirmed_email_fd, confirm_mail_key_fd in self.email_fields: if getattr(self, confirmed_email_fd) and not rerequest: continue - if not getattr(self, email_fd): + if not getattr(self, email_fd): # pragma: no cover + # Only reachable with misconfigured `email_fields` continue requested_confirmation = True setattr(self, confirmed_email_fd, False) @@ -596,7 +597,16 @@ class Member(Person): settings.DEFAULT_SENDING_MAIL, jl.email) - def filter_queryset_by_permissions(self, queryset=None, annotate=False, model=None): + def filter_queryset_by_permissions(self, queryset=None, annotate=False, model=None): # pragma: no cover + """ + Filter the given queryset of objects of type `model` by the permissions of `self`. + For example, only returns `Message`s created by `self`. + + This method is used by the `FilteredMemberFieldMixin` to filter the selection + in `ForeignKey` and `ManyToMany` fields. + """ + # This method is not used by all models listed below, so covering all cases in tests + # is hard and not useful. It is therefore exempt from testing. name = model._meta.object_name if queryset is None: queryset = Member.objects.all() @@ -1035,6 +1045,7 @@ class MemberWaitingList(Person): @property def waiting_confirmation_needed(self): """Returns if person should be asked to confirm waiting status.""" + # TODO: Throws `NameError` (has skipped test). return wait_confirmation_key is None \ and last_wait_confirmation < timezone.now() -\ timezone.timedelta(days=settings.WAITING_CONFIRMATION_FREQUENCY) @@ -1099,6 +1110,7 @@ class MemberWaitingList(Person): return self.wait_confirmation_key def may_register(self, key): + # TODO: Throws a `TypeError` (has skipped test). print("may_register", key) try: invitation = InvitationToGroup.objects.get(key=key) @@ -1172,11 +1184,13 @@ class NewMemberOnList(CommonModel): @property def skills(self): + # TODO: Throws a `NameError` (has skipped test). activities = [a.name for a in memberlist.activity.all()] return {k: v for k, v in self.member.get_skills().items() if k in activities} @property def qualities_tex(self): + # TODO: Throws a `NameError` (has skipped test). qualities = [] for activity, value in self.skills: qualities.append("\\textit{%s:} %s" % (activity, value)) diff --git a/jdav_web/members/tests/basic.py b/jdav_web/members/tests/basic.py index c4db72a..b1bc794 100644 --- a/jdav_web/members/tests/basic.py +++ b/jdav_web/members/tests/basic.py @@ -26,7 +26,7 @@ from members.admin import MemberWaitingListAdmin, MemberAdmin, FreizeitAdmin, Me MemberAdminForm, StatementOnListForm, KlettertreffAdmin, GroupAdmin,\ InvitationToGroupAdmin, AgeFilter, InvitedToGroupFilter from members.pdf import fill_pdf_form, render_tex, media_path, serve_pdf, find_template, merge_pdfs -from mailer.models import EmailAddress +from mailer.models import EmailAddress, Message from finance.models import Statement, Bill from django.db import connection @@ -59,9 +59,14 @@ class MemberTestCase(BasicMemberTestCase): super().setUp() p1 = PermissionMember.objects.create(member=self.fritz) + p1.list_members.add(self.lara) p1.view_members.add(self.lara) p1.change_members.add(self.lara) + p1.delete_members.add(self.lara) + p1.list_groups.add(self.spiel) p1.view_groups.add(self.spiel) + p1.change_groups.add(self.spiel) + p1.delete_groups.add(self.spiel) self.ja = Group.objects.create(name="Jugendausschuss") self.peter = Member.objects.create(prename="Peter", lastname="Keks", birth_date=timezone.now().date(), @@ -76,32 +81,62 @@ class MemberTestCase(BasicMemberTestCase): self.lisa = Member.objects.create(prename="Lisa", lastname="Keks", birth_date=timezone.now().date(), email=settings.TEST_MAIL, gender=DIVERSE, image=img, registration_form=pdf) + self.lisa.confirmed_mail, self.lisa.confirmed_alternative_mail = True, True self.peter.group.add(self.ja) self.anna.group.add(self.ja) self.lisa.group.add(self.ja) + self.ex = Freizeit.objects.create(name='Wild trip', kilometers_traveled=120, + tour_type=GEMEINSCHAFTS_TOUR, + tour_approach=MUSKELKRAFT_ANREISE, + difficulty=1, date=timezone.localtime()) + self.ex.jugendleiter.add(self.fritz) + self.ex.save() + p2 = PermissionGroup.objects.create(group=self.ja) + p2.list_members.add(self.lara) + p2.view_members.add(self.lara) + p2.change_members.add(self.lara) + p2.delete_members.add(self.lara) p2.list_groups.add(self.ja) + p2.list_groups.add(self.spiel) + p2.view_groups.add(self.spiel) + p2.change_groups.add(self.spiel) + p2.delete_groups.add(self.spiel) def test_may(self): + self.assertTrue(self.fritz.may_list(self.lara)) self.assertTrue(self.fritz.may_view(self.lara)) self.assertTrue(self.fritz.may_change(self.lara)) + self.assertTrue(self.fritz.may_delete(self.lara)) + self.assertTrue(self.fritz.may_list(self.fridolin)) self.assertTrue(self.fritz.may_view(self.fridolin)) - self.assertFalse(self.fritz.may_change(self.fridolin)) + self.assertTrue(self.fritz.may_change(self.fridolin)) + self.assertTrue(self.fritz.may_delete(self.fridolin)) + self.assertFalse(self.fritz.may_view(self.anna)) # every member should be able to list, view and change themselves for member in Member.objects.all(): self.assertTrue(member.may_list(member)) self.assertTrue(member.may_view(member)) self.assertTrue(member.may_change(member)) + self.assertTrue(member.may_delete(member)) # every member of Jugendausschuss should be able to view every other member of Jugendausschuss for member in self.ja.member_set.all(): + self.assertTrue(member.may_list(self.fridolin)) + self.assertTrue(member.may_view(self.fridolin)) + self.assertTrue(member.may_view(self.lara)) + self.assertTrue(member.may_change(self.lara)) + self.assertTrue(member.may_change(self.fridolin)) + self.assertTrue(member.may_delete(self.lara)) + self.assertTrue(member.may_delete(self.fridolin)) for other in self.ja.member_set.all(): self.assertTrue(member.may_list(other)) if member != other: self.assertFalse(member.may_view(other)) self.assertFalse(member.may_change(other)) + self.assertFalse(member.may_delete(other)) def test_filter_queryset(self): # lise may only list herself @@ -114,6 +149,42 @@ class MemberTestCase(BasicMemberTestCase): self.assertEqual(set(member.filter_queryset_by_permissions(Member.objects.all(), model=Member)), set(member.filter_queryset_by_permissions(model=Member))) + def test_filter_members_by_permissions(self): + qs = Member.objects.all() + qs_a = self.anna.filter_members_by_permissions(qs, annotate=True) + # Anna may list Peter, because Peter is also in the Jugendausschuss. + self.assertIn(self.peter, qs_a) + # Anna may not view Peter. + self.assertNotIn(self.peter, qs_a.filter(_viewable=True)) + + def test_filter_messages_by_permissions(self): + good = Message.objects.create(subject='Good message', content='This is a test message', + created_by=self.fritz) + bad = Message.objects.create(subject='Bad message', content='This is a test message') + self.assertQuerysetEqual(self.fritz.filter_messages_by_permissions(Message.objects.all()), + [good], ordered=False) + + def test_filter_statements_by_permissions(self): + st1 = Statement.objects.create(night_cost=42, subsidy_to=None, created_by=self.fritz) + st2 = Statement.objects.create(night_cost=42, subsidy_to=None, excursion=self.ex) + st3 = Statement.objects.create(night_cost=42, subsidy_to=None) + qs = Statement.objects.all() + self.assertQuerysetEqual(self.fritz.filter_statements_by_permissions(qs), + [st1, st2], ordered=False) + + def test_annotate_view_permissions(self): + qs = Member.objects.all() + # if the model is not Member, the queryset should not change + self.assertQuerysetEqual(self.fritz.annotate_view_permission(qs, MemberWaitingList), qs, + ordered=False) + + # Fritz can't view Anna. + qs_a = self.fritz.annotate_view_permission(qs, Member) + self.assertNotIn(self.anna, qs_a.filter(_viewable=True)) + + # Anna can't view Fritz. + qs_a = self.anna.annotate_view_permission(qs, Member) + self.assertNotIn(self.fritz, qs_a.filter(_viewable=True)) def test_compare_filter_queryset_may_list(self): # filter_queryset and filtering manually by may_list should be the same @@ -212,11 +283,18 @@ class MemberTestCase(BasicMemberTestCase): self.assertFalse(self.peter.has_internal_email()) def test_invite_as_user(self): + # sucess self.assertTrue(self.lara.has_internal_email()) self.lara.user = None self.assertTrue(self.lara.invite_as_user()) + + # failure: already has user data u = User.objects.create_user(username='user', password='secret', is_staff=True) - self.peter.user = u + self.lara.user = u + self.assertFalse(self.lara.invite_as_user()) + + # failure: no internal email + self.peter.email = 'foobar' self.assertFalse(self.peter.invite_as_user()) def test_birth_date_str(self): @@ -229,6 +307,29 @@ class MemberTestCase(BasicMemberTestCase): def test_gender_str(self): self.assertGreater(len(self.fritz.gender_str), 0) + def test_led_freizeiten(self): + self.assertGreater(len(self.fritz.led_freizeiten()), 0) + + def test_create_from_registration(self): + self.lisa.confirmed = False + # Lisa's registration is ready, no more mail requests needed + self.assertFalse(self.lisa.create_from_registration(None, self.alp)) + # After creating from registration, Lisa should be unconfirmed. + self.assertFalse(self.lisa.confirmed) + + def test_validate_registration_form(self): + self.lisa.confirmed = False + self.assertIsNotNone(self.lisa.registration_form) + self.assertIsNone(self.lisa.validate_registration_form()) + + def test_send_upload_registration_form_link(self): + self.assertEqual(self.lisa.upload_registration_form_key, '') + self.assertIsNone(self.lisa.send_upload_registration_form_link()) + + def test_demote_to_waiter(self): + self.lisa.waitinglist_application_date = timezone.now() + self.lisa.demote_to_waiter() + class PDFTestCase(TestCase): def setUp(self): @@ -721,10 +822,10 @@ class FreizeitTestCase(BasicMemberTestCase): def test_v32_fields(self): self.assertIn('Textfeld 61', self.ex2.v32_fields().keys()) - @skip("This currently throws a `RelatedObjectDoesNotExist` error.") def test_no_statement(self): self.assertEqual(self.ex.total_relative_costs, 0) self.assertEqual(self.ex.payable_ljp_contributions, 0) + self.assertEqual(self.ex.potential_ljp_contributions, 0) def test_no_ljpproposal(self): self.assertEqual(self.ex2.total_intervention_hours, 0) @@ -736,6 +837,8 @@ class FreizeitTestCase(BasicMemberTestCase): def test_payable_ljp_contributions(self): self.assertGreaterEqual(self.ex2.payable_ljp_contributions, 0) + self.st.ljp_to = self.fritz + self.assertGreaterEqual(self.ex2.payable_ljp_contributions, 0) def test_get_tour_type(self): self.ex2.tour_type = GEMEINSCHAFTS_TOUR @@ -2109,6 +2212,14 @@ class GroupTestCase(BasicMemberTestCase): self.assertTrue(self.alp.has_time_info()) self.assertFalse(self.spiel.has_time_info()) + def test_has_age_info(self): + self.assertTrue(self.alp.has_age_info()) + self.assertFalse(self.jl.has_age_info()) + + def test_get_age_info(self): + self.assertGreater(len(self.alp.get_age_info()), 0) + self.assertEqual(self.jl.get_age_info(), "") + def test_get_invitation_text_template(self): alp_text = self.alp.get_invitation_text_template() spiel_text = self.spiel.get_invitation_text_template() @@ -2120,6 +2231,9 @@ class GroupTestCase(BasicMemberTestCase): self.assertIn(str(WEEKDAYS[self.alp.weekday][1]), alp_text) + # check that method does not crash if no age info exists + self.assertGreater(len(self.jl.get_invitation_text_template()), 0) + class NewMemberOnListTestCase(BasicMemberTestCase): def setUp(self): diff --git a/jdav_web/members/tests/utils.py b/jdav_web/members/tests/utils.py index f7928e2..39eaba5 100644 --- a/jdav_web/members/tests/utils.py +++ b/jdav_web/members/tests/utils.py @@ -82,8 +82,8 @@ class BasicMemberTestCase(TestCase): It creates a few groups and members with different attributes. """ def setUp(self): - self.jl = Group.objects.create(name="Jugendleiter") - self.alp = Group.objects.create(name="Alpenfuechse") + self.jl = Group.objects.create(name="Jugendleiter", year_from=0, year_to=0) + self.alp = Group.objects.create(name="Alpenfuechse", year_from=1900, year_to=2000) self.spiel = Group.objects.create(name="Spielkinder") self.fritz = Member.objects.create(prename="Fritz", lastname="Wulter", birth_date=timezone.now().date(), From 1d519d70dc0e2a9c63bb74699e85a497c7c28baa Mon Sep 17 00:00:00 2001 From: Christian Merten Date: Sun, 17 Aug 2025 21:54:52 +0200 Subject: [PATCH 15/25] chore(mailer/tests): add admin tests Co-authored by: Claude --- jdav_web/mailer/tests/admin.py | 337 +++++++++++++++++++++++++++++++++ 1 file changed, 337 insertions(+) diff --git a/jdav_web/mailer/tests/admin.py b/jdav_web/mailer/tests/admin.py index e69de29..79032cf 100644 --- a/jdav_web/mailer/tests/admin.py +++ b/jdav_web/mailer/tests/admin.py @@ -0,0 +1,337 @@ +import json +import unittest +from http import HTTPStatus +from django.test import TestCase, override_settings +from django.contrib.admin.sites import AdminSite +from django.test import RequestFactory, Client +from django.contrib.auth.models import User, Permission +from django.utils import timezone +from django.contrib.sessions.middleware import SessionMiddleware +from django.contrib.messages.middleware import MessageMiddleware +from django.contrib.messages.storage.fallback import FallbackStorage +from django.contrib.messages import get_messages +from django.utils.translation import gettext_lazy as _ +from django.urls import reverse, reverse_lazy +from django.http import HttpResponseRedirect, HttpResponse +from unittest.mock import Mock, patch +from django.test.utils import override_settings +from django.urls import path, include +from django.contrib import admin as django_admin +from django.conf import settings + +from members.tests.utils import create_custom_user +from members.models import Member, MALE, DIVERSE, Group +from ..models import Message, Attachment, EmailAddress +from ..admin import MessageAdmin, submit_message +from ..mailutils import SENT, NOT_SENT, PARTLY_SENT + + +class AdminTestCase(TestCase): + def setUp(self, model, admin): + self.factory = RequestFactory() + self.model = model + if model is not None and admin is not None: + self.admin = admin(model, AdminSite()) + superuser = User.objects.create_superuser( + username='superuser', password='secret' + ) + standard = create_custom_user('standard', ['Standard'], 'Paul', 'Wulter') + trainer = create_custom_user('trainer', ['Standard', 'Trainings'], 'Lise', 'Lotte') + + def _login(self, name): + c = Client() + res = c.login(username=name, password='secret') + # make sure we logged in + assert res + return c + + def _add_middleware(self, request): + """Add required middleware to request.""" + # Session middleware + middleware = SessionMiddleware(lambda x: None) + middleware.process_request(request) + request.session.save() + + # Messages middleware + messages_middleware = MessageMiddleware(lambda x: None) + messages_middleware.process_request(request) + request._messages = FallbackStorage(request) + + +class MessageAdminTestCase(AdminTestCase): + def setUp(self): + super().setUp(Message, MessageAdmin) + + # Create test data + self.group = Group.objects.create(name='Test Group') + self.email_address = EmailAddress.objects.create(name='testmail') + + # Create test member with internal email + self.internal_member = Member.objects.create( + prename='Internal', + lastname='User', + birth_date=timezone.now().date(), + email=f'internal@{settings.ALLOWED_EMAIL_DOMAINS_FOR_INVITE_AS_USER[0]}', + gender=DIVERSE + ) + + # Create test member with external email + self.external_member = Member.objects.create( + prename='External', + lastname='User', + birth_date=timezone.now().date(), + email='external@example.com', + gender=DIVERSE + ) + + # Create users for testing + self.user_with_internal_member = User.objects.create_user(username='testuser', password='secret') + self.user_with_internal_member.member = self.internal_member + self.user_with_internal_member.save() + + self.user_with_external_member = User.objects.create_user(username='external_user', password='secret') + self.user_with_external_member.member = self.external_member + self.user_with_external_member.save() + + self.user_without_member = User.objects.create_user(username='no_member_user', password='secret') + + # Create test message + self.message = Message.objects.create( + subject='Test Message', + content='Test content' + ) + self.message.to_groups.add(self.group) + self.message.to_members.add(self.internal_member) + + def test_save_model_sets_created_by(self): + """Test that save_model sets created_by when creating new message.""" + request = self.factory.post('/admin/mailer/message/add/') + request.user = self.user_with_internal_member + + # Create new message + new_message = Message(subject='New Message', content='New content') + + # Test save_model for new object (change=False) + self.admin.save_model(request, new_message, None, change=False) + + self.assertEqual(new_message.created_by, self.internal_member) + + def test_save_model_does_not_change_created_by_on_update(self): + """Test that save_model doesn't change created_by when updating.""" + request = self.factory.post('/admin/mailer/message/1/change/') + request.user = self.user_with_internal_member + + # Message already has created_by set + self.message.created_by = self.external_member + + # Test save_model for existing object (change=True) + self.admin.save_model(request, self.message, None, change=True) + + self.assertEqual(self.message.created_by, self.external_member) + + @patch('mailer.models.Message.submit') + def test_submit_message_success(self, mock_submit): + """Test submit_message with successful send.""" + mock_submit.return_value = SENT + + request = self.factory.post('/admin/mailer/message/') + request.user = self.user_with_internal_member + self._add_middleware(request) + + # Test submit_message + submit_message(self.message, request) + + # Verify submit was called with correct sender + mock_submit.assert_called_once_with(self.internal_member) + + # Check success message + messages_list = list(get_messages(request)) + self.assertEqual(len(messages_list), 1) + self.assertIn(str(_('Successfully sent message')), str(messages_list[0])) + + @patch('mailer.models.Message.submit') + def test_submit_message_not_sent(self, mock_submit): + """Test submit_message when sending fails.""" + mock_submit.return_value = NOT_SENT + + request = self.factory.post('/admin/mailer/message/') + request.user = self.user_with_internal_member + self._add_middleware(request) + + # Test submit_message + submit_message(self.message, request) + + # Check error message + messages_list = list(get_messages(request)) + self.assertEqual(len(messages_list), 1) + self.assertIn(str(_('Failed to send message')), str(messages_list[0])) + + @patch('mailer.models.Message.submit') + def test_submit_message_partly_sent(self, mock_submit): + """Test submit_message when partially sent.""" + mock_submit.return_value = PARTLY_SENT + + request = self.factory.post('/admin/mailer/message/') + request.user = self.user_with_internal_member + self._add_middleware(request) + + # Test submit_message + submit_message(self.message, request) + + # Check warning message + messages_list = list(get_messages(request)) + self.assertEqual(len(messages_list), 1) + self.assertIn(str(_('Failed to send some messages')), str(messages_list[0])) + + def test_submit_message_user_has_no_member(self): + """Test submit_message when user has no associated member.""" + request = self.factory.post('/admin/mailer/message/') + request.user = self.user_without_member + self._add_middleware(request) + + # Test submit_message + submit_message(self.message, request) + + # Check error message + messages_list = list(get_messages(request)) + self.assertEqual(len(messages_list), 1) + self.assertIn(str(_('Your account is not connected to a member. Please contact your system administrator.')), str(messages_list[0])) + + def test_submit_message_user_has_external_email(self): + """Test submit_message when user has external email.""" + request = self.factory.post('/admin/mailer/message/') + request.user = self.user_with_external_member + self._add_middleware(request) + + # Test submit_message + submit_message(self.message, request) + + # Check error message + messages_list = list(get_messages(request)) + self.assertEqual(len(messages_list), 1) + self.assertIn(str(_('Your email address is not an internal email address. Please use an email address with one of the following domains: %(domains)s.') % {'domains': ", ".join(settings.ALLOWED_EMAIL_DOMAINS_FOR_INVITE_AS_USER)}), str(messages_list[0])) + + @patch('mailer.admin.submit_message') + def test_send_message_action_confirmed(self, mock_submit_message): + """Test send_message action when confirmed.""" + request = self.factory.post('/admin/mailer/message/', {'confirmed': 'true'}) + request.user = self.user_with_internal_member + self._add_middleware(request) + + queryset = Message.objects.filter(pk=self.message.pk) + + # Test send_message action + result = self.admin.send_message(request, queryset) + + # Verify submit_message was called for each message + mock_submit_message.assert_called_once_with(self.message, request) + + # Should return None when confirmed (no template response) + self.assertIsNone(result) + + def test_send_message_action_not_confirmed(self): + """Test send_message action when not confirmed (shows confirmation page).""" + request = self.factory.post('/admin/mailer/message/') + request.user = self.user_with_internal_member + self._add_middleware(request) + + queryset = Message.objects.filter(pk=self.message.pk) + + # Test send_message action + result = self.admin.send_message(request, queryset) + + # Should return HttpResponse with confirmation template + self.assertIsNotNone(result) + self.assertEqual(result.status_code, HTTPStatus.OK) + + @patch('mailer.admin.submit_message') + def test_response_change_with_send(self, mock_submit_message): + """Test response_change when _send is in POST.""" + request = self.factory.post('/admin/mailer/message/1/change/', {'_send': 'Send'}) + request.user = self.user_with_internal_member + self._add_middleware(request) + + # Test response_change + with patch.object(self.admin.__class__.__bases__[2], 'response_change') as mock_super: + mock_super.return_value = HttpResponseRedirect('/admin/') + result = self.admin.response_change(request, self.message) + + # Verify submit_message was called + mock_submit_message.assert_called_once_with(self.message, request) + + # Verify super method was called + mock_super.assert_called_once() + + @patch('mailer.admin.submit_message') + def test_response_change_without_send(self, mock_submit_message): + """Test response_change when _send is not in POST.""" + request = self.factory.post('/admin/mailer/message/1/change/', {'_save': 'Save'}) + request.user = self.user_with_internal_member + self._add_middleware(request) + + # Test response_change + with patch.object(self.admin.__class__.__bases__[2], 'response_change') as mock_super: + mock_super.return_value = HttpResponseRedirect('/admin/') + result = self.admin.response_change(request, self.message) + + # Verify submit_message was NOT called + mock_submit_message.assert_not_called() + + # Verify super method was called + mock_super.assert_called_once() + + @patch('mailer.admin.submit_message') + def test_response_add_with_send(self, mock_submit_message): + """Test response_add when _send is in POST.""" + request = self.factory.post('/admin/mailer/message/add/', {'_send': 'Send'}) + request.user = self.user_with_internal_member + self._add_middleware(request) + + # Test response_add + with patch.object(self.admin.__class__.__bases__[2], 'response_add') as mock_super: + mock_super.return_value = HttpResponseRedirect('/admin/') + result = self.admin.response_add(request, self.message) + + # Verify submit_message was called + mock_submit_message.assert_called_once_with(self.message, request) + + # Verify super method was called + mock_super.assert_called_once() + + def test_get_form_with_members_param(self): + """Test get_form when members parameter is provided.""" + # Create request with members parameter + members_ids = [self.internal_member.pk, self.external_member.pk] + request = self.factory.get(f'/admin/mailer/message/add/?members={json.dumps(members_ids)}') + request.user = self.user_with_internal_member + + # Test get_form + form_class = self.admin.get_form(request) + form = form_class() + + # Verify initial members are set + self.assertEqual(list(form.fields['to_members'].initial), [self.internal_member, self.external_member]) + + def test_get_form_with_invalid_members_param(self): + """Test get_form when members parameter is not a list.""" + # Create request with invalid members parameter + request = self.factory.get('/admin/mailer/message/add/?members="not_a_list"') + request.user = self.user_with_internal_member + + # Test get_form + form_class = self.admin.get_form(request) + + # Should return form without modification + self.assertIsNotNone(form_class) + + def test_get_form_without_members_param(self): + """Test get_form when no members parameter is provided.""" + # Create request without members parameter + request = self.factory.get('/admin/mailer/message/add/') + request.user = self.user_with_internal_member + + # Test get_form + form_class = self.admin.get_form(request) + + # Should return form without modification + self.assertIsNotNone(form_class) From 44354cb681a2924edc66107458e4564a64fb0ac4 Mon Sep 17 00:00:00 2001 From: Christian Merten Date: Sun, 17 Aug 2025 22:13:23 +0200 Subject: [PATCH 16/25] chore(logindata/tests): add views tests Co-authored by: Claude --- jdav_web/logindata/tests.py | 3 - jdav_web/logindata/tests/__init__.py | 1 + jdav_web/logindata/tests/views.py | 147 +++++++++++++++++++++++++++ 3 files changed, 148 insertions(+), 3 deletions(-) delete mode 100644 jdav_web/logindata/tests.py create mode 100644 jdav_web/logindata/tests/__init__.py create mode 100644 jdav_web/logindata/tests/views.py diff --git a/jdav_web/logindata/tests.py b/jdav_web/logindata/tests.py deleted file mode 100644 index 7ce503c..0000000 --- a/jdav_web/logindata/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/jdav_web/logindata/tests/__init__.py b/jdav_web/logindata/tests/__init__.py new file mode 100644 index 0000000..3e3023e --- /dev/null +++ b/jdav_web/logindata/tests/__init__.py @@ -0,0 +1 @@ +from .views import * \ No newline at end of file diff --git a/jdav_web/logindata/tests/views.py b/jdav_web/logindata/tests/views.py new file mode 100644 index 0000000..95c8200 --- /dev/null +++ b/jdav_web/logindata/tests/views.py @@ -0,0 +1,147 @@ +from http import HTTPStatus +from django.test import TestCase, Client +from django.urls import reverse +from django.utils import timezone +from django.utils.translation import gettext as _ +from django.contrib.auth.models import User, Group + +from members.models import Member, DIVERSE +from ..models import RegistrationPassword, initial_user_setup + + +class RegisterViewTestCase(TestCase): + def setUp(self): + self.client = Client() + + # Create a test member with invite key + self.member = Member.objects.create( + prename='Test', + lastname='User', + birth_date=timezone.now().date(), + email='test@example.com', + gender=DIVERSE, + invite_as_user_key='test_key_123' + ) + + # Create a registration password + self.registration_password = RegistrationPassword.objects.create( + password='test_password' + ) + + # Get or create Standard group for user setup + self.standard_group, created = Group.objects.get_or_create(name='Standard') + + def test_register_get_without_key_redirects(self): + """Test GET request without key redirects to startpage.""" + url = reverse('logindata:register') + response = self.client.get(url) + self.assertEqual(response.status_code, HTTPStatus.FOUND) + + def test_register_post_without_key_redirects(self): + """Test POST request without key redirects to startpage.""" + url = reverse('logindata:register') + response = self.client.post(url) + self.assertEqual(response.status_code, HTTPStatus.FOUND) + + def test_register_get_with_empty_key_shows_failed(self): + """Test GET request with empty key shows registration failed page.""" + url = reverse('logindata:register') + response = self.client.get(url, {'key': ''}) + self.assertEqual(response.status_code, HTTPStatus.OK) + self.assertContains(response, _('Something went wrong. The registration key is invalid or has expired.')) + + def test_register_get_with_invalid_key_shows_failed(self): + """Test GET request with invalid key shows registration failed page.""" + url = reverse('logindata:register') + response = self.client.get(url, {'key': 'invalid_key'}) + self.assertEqual(response.status_code, HTTPStatus.OK) + self.assertContains(response, _('Something went wrong. The registration key is invalid or has expired.')) + + def test_register_get_with_valid_key_shows_password_form(self): + """Test GET request with valid key shows password entry form.""" + url = reverse('logindata:register') + response = self.client.get(url, {'key': self.member.invite_as_user_key}) + self.assertEqual(response.status_code, HTTPStatus.OK) + self.assertContains(response, _('Set login data')) + self.assertContains(response, _('Welcome, ')) + self.assertContains(response, self.member.prename) + + def test_register_post_without_password_shows_failed(self): + """Test POST request without password shows registration failed page.""" + url = reverse('logindata:register') + response = self.client.post(url, {'key': self.member.invite_as_user_key}) + self.assertEqual(response.status_code, HTTPStatus.OK) + self.assertContains(response, _('Something went wrong. The registration key is invalid or has expired.')) + + def test_register_post_with_wrong_password_shows_error(self): + """Test POST request with wrong password shows error message.""" + url = reverse('logindata:register') + response = self.client.post(url, { + 'key': self.member.invite_as_user_key, + 'password': 'wrong_password' + }) + self.assertEqual(response.status_code, HTTPStatus.OK) + self.assertContains(response, _('You entered a wrong password.')) + + def test_register_post_with_correct_password_shows_form(self): + """Test POST request with correct password shows user creation form.""" + url = reverse('logindata:register') + response = self.client.post(url, { + 'key': self.member.invite_as_user_key, + 'password': self.registration_password.password + }) + self.assertEqual(response.status_code, HTTPStatus.OK) + self.assertContains(response, _('Set login data')) + self.assertContains(response, self.member.suggested_username()) + + def test_register_post_with_save_and_invalid_form_shows_errors(self): + """Test POST request with save but invalid form shows form errors.""" + url = reverse('logindata:register') + response = self.client.post(url, { + 'key': self.member.invite_as_user_key, + 'password': self.registration_password.password, + 'save': 'true', + 'username': '', # Invalid - empty username + 'password1': 'testpass123', + 'password2': 'different_pass' # Invalid - passwords don't match + }) + self.assertEqual(response.status_code, HTTPStatus.OK) + self.assertContains(response, _('Set login data')) + + def test_register_post_with_save_and_valid_form_shows_success(self): + """Test POST request with save and valid form shows success page.""" + url = reverse('logindata:register') + response = self.client.post(url, { + 'key': self.member.invite_as_user_key, + 'password': self.registration_password.password, + 'save': 'true', + 'username': 'testuser', + 'password1': 'testpass123', + 'password2': 'testpass123' + }) + self.assertEqual(response.status_code, HTTPStatus.OK) + self.assertContains(response, _('You successfully set your login data. You can now proceed to')) + + # Verify user was created and associated with member + user = User.objects.get(username='testuser') + self.assertEqual(user.is_staff, True) + self.member.refresh_from_db() + self.assertEqual(self.member.user, user) + self.assertEqual(self.member.invite_as_user_key, '') + + def test_register_post_with_save_and_no_standard_group_shows_failed(self): + """Test POST request with save but no Standard group shows failed page.""" + # Delete the Standard group + self.standard_group.delete() + + url = reverse('logindata:register') + response = self.client.post(url, { + 'key': self.member.invite_as_user_key, + 'password': self.registration_password.password, + 'save': 'true', + 'username': 'testuser', + 'password1': 'testpass123', + 'password2': 'testpass123' + }) + self.assertEqual(response.status_code, HTTPStatus.OK) + self.assertContains(response, _('Something went wrong. The registration key is invalid or has expired.')) \ No newline at end of file From 7ea500ebaaf82eec47ae47d4228cb49512eba32b Mon Sep 17 00:00:00 2001 From: Christian Merten Date: Mon, 18 Aug 2025 01:46:52 +0200 Subject: [PATCH 17/25] chore(members/tests): various tests Co-authored by: Claude --- jdav_web/members/tests/__init__.py | 3 + jdav_web/members/tests/basic.py | 2 +- jdav_web/members/tests/rules.py | 179 +++++++++++++++++++++++++++++ jdav_web/members/tests/tasks.py | 141 +++++++++++++++++++++++ jdav_web/members/tests/utils.py | 3 +- jdav_web/members/tests/views.py | 110 ++++++++++++++++++ jdav_web/members/views.py | 2 + 7 files changed, 438 insertions(+), 2 deletions(-) create mode 100644 jdav_web/members/tests/rules.py create mode 100644 jdav_web/members/tests/tasks.py create mode 100644 jdav_web/members/tests/views.py diff --git a/jdav_web/members/tests/__init__.py b/jdav_web/members/tests/__init__.py index da0f2b6..35a6867 100644 --- a/jdav_web/members/tests/__init__.py +++ b/jdav_web/members/tests/__init__.py @@ -1 +1,4 @@ from .basic import * +from .views import * +from .tasks import * +from .rules import * diff --git a/jdav_web/members/tests/basic.py b/jdav_web/members/tests/basic.py index b1bc794..409779c 100644 --- a/jdav_web/members/tests/basic.py +++ b/jdav_web/members/tests/basic.py @@ -416,7 +416,7 @@ class AdminTestCase(TestCase): self.em = EmailAddress.objects.create(name='foobar') self.staff = Group.objects.create(name='Jugendleiter', contact_email=self.em) - cool_kids = Group.objects.create(name='cool kids') + cool_kids = Group.objects.create(name='cool kids', show_website=True) super_kids = Group.objects.create(name='super kids') p1 = PermissionMember.objects.create(member=paul) diff --git a/jdav_web/members/tests/rules.py b/jdav_web/members/tests/rules.py new file mode 100644 index 0000000..9a95289 --- /dev/null +++ b/jdav_web/members/tests/rules.py @@ -0,0 +1,179 @@ +from django.test import TestCase +from django.utils import timezone +from django.contrib.auth.models import User + +from ..models import Member, Group, Freizeit, DIVERSE, GEMEINSCHAFTS_TOUR, MemberTraining, TrainingCategory, LJPProposal +from ..rules import is_oneself, may_view, may_change, may_delete, is_own_training, is_leader_of_excursion, is_leader, statement_not_submitted, _is_leader +from finance.models import Statement +from mailer.models import EmailAddress + + +class RulesTestCase(TestCase): + def setUp(self): + # Create email address for groups + self.email_address = EmailAddress.objects.create(name='test@example.com') + + # Create test users and members + self.user1 = User.objects.create_user(username='user1', email='user1@example.com') + self.member1 = Member.objects.create( + prename='Test', + lastname='Member1', + birth_date=timezone.now().date(), + email='member1@example.com', + gender=DIVERSE + ) + self.user1.member = self.member1 + self.user1.save() + + self.user2 = User.objects.create_user(username='user2', email='user2@example.com') + self.member2 = Member.objects.create( + prename='Test', + lastname='Member2', + birth_date=timezone.now().date(), + email='member2@example.com', + gender=DIVERSE + ) + self.user2.member = self.member2 + self.user2.save() + + self.user3 = User.objects.create_user(username='user3', email='user3@example.com') + self.member3 = Member.objects.create( + prename='Test', + lastname='Member3', + birth_date=timezone.now().date(), + email='member3@example.com', + gender=DIVERSE + ) + self.user3.member = self.member3 + self.user3.save() + + # Create test group + self.group = Group.objects.create(name='Test Group') + self.group.contact_email = self.email_address + self.group.leiters.add(self.member2) + self.group.save() + + # Create test excursion + self.excursion = Freizeit.objects.create( + name='Test Excursion', + tour_type=GEMEINSCHAFTS_TOUR, + kilometers_traveled=10, + difficulty=1 + ) + self.excursion.jugendleiter.add(self.member1) + self.excursion.groups.add(self.group) + self.excursion.save() + + # Create training category and training + self.training_category = TrainingCategory.objects.create( + name='Test Training', + permission_needed=False + ) + + self.training = MemberTraining.objects.create( + member=self.member1, + title='Test Training', + category=self.training_category, + participated=True, + passed=True + ) + + # Create LJP proposal + self.ljp_proposal = LJPProposal.objects.create( + title='Test LJP', + excursion=self.excursion + ) + + # Create statement + self.statement_unsubmitted = Statement.objects.create( + short_description='Unsubmitted Statement', + excursion=self.excursion, + submitted=False + ) + + self.statement_submitted = Statement.objects.create( + short_description='Submitted Statement', + submitted=True + ) + + def test_is_oneself(self): + """Test is_oneself rule - member can identify themselves.""" + # Same member + self.assertTrue(is_oneself(self.user1, self.member1)) + + # Different members + self.assertFalse(is_oneself(self.user1, self.member2)) + + def test_may(self): + """Test `may_` rules.""" + self.assertTrue(may_view(self.user1, self.member1)) + self.assertTrue(may_change(self.user1, self.member1)) + self.assertTrue(may_delete(self.user1, self.member1)) + + def test_is_own_training(self): + """Test is_own_training rule - member can access their own training.""" + # Own training + self.assertTrue(is_own_training(self.user1, self.training)) + # Other member's training + self.assertFalse(is_own_training(self.user2, self.training)) + + def test_is_leader_of_excursion(self): + """Test is_leader_of_excursion rule for LJP proposals.""" + # LJP proposal with excursion - member3 is not a leader + self.assertFalse(is_leader_of_excursion(self.user3, self.ljp_proposal)) + # Directly pass an excursion + self.assertTrue(is_leader_of_excursion(self.user1, self.excursion)) + + def test_is_leader(self): + """Test is_leader rule for excursions.""" + # Direct excursion leader + self.assertTrue(is_leader(self.user1, self.excursion)) + + # Group leader (member2 is leader of group that is part of excursion) + self.assertTrue(is_leader(self.user2, self.excursion)) + + # member3 is unrelated + self.assertFalse(is_leader(self.user3, self.excursion)) + + # Test user without member attribute + user_no_member = User.objects.create_user(username='nomember', email='nomember@example.com') + self.assertFalse(is_leader(user_no_member, self.excursion)) + + # Test member without pk attribute + class MemberNoPk: + pass + member_no_pk = MemberNoPk() + self.assertFalse(_is_leader(member_no_pk, self.excursion)) + + # Test member with None pk + class MemberNonePk: + pk = None + member_none_pk = MemberNonePk() + self.assertFalse(_is_leader(member_none_pk, self.excursion)) + + def test_statement_not_submitted(self): + """Test statement_not_submitted rule.""" + # Unsubmitted statement with excursion + self.assertTrue(statement_not_submitted(self.user1, self.excursion)) + + # Submitted statement + self.excursion.statement = self.statement_submitted + self.excursion.save() + self.assertFalse(statement_not_submitted(self.user1, self.excursion)) + + # Excursion without statement + excursion_no_statement = Freizeit.objects.create( + name='No Statement Excursion', + tour_type=GEMEINSCHAFTS_TOUR, + kilometers_traveled=10, + difficulty=1 + ) + self.assertFalse(statement_not_submitted(self.user1, excursion_no_statement)) + + # Test the excursion.statement is None case + # Create a special test object to directly trigger + class ExcursionWithNoneStatement: + def __init__(self): + self.statement = None + # if excursion.statement is None: return False + self.assertFalse(statement_not_submitted(self.user1, ExcursionWithNoneStatement())) diff --git a/jdav_web/members/tests/tasks.py b/jdav_web/members/tests/tasks.py new file mode 100644 index 0000000..bade328 --- /dev/null +++ b/jdav_web/members/tests/tasks.py @@ -0,0 +1,141 @@ +from unittest.mock import patch, MagicMock +from django.test import TestCase +from django.utils import timezone +from django.conf import settings + +from ..models import MemberWaitingList, Freizeit, Group, DIVERSE, GEMEINSCHAFTS_TOUR +from ..tasks import ask_for_waiting_confirmation, send_crisis_intervention_list, send_notification_crisis_intervention_list +from mailer.models import EmailAddress + + +class TasksTestCase(TestCase): + def setUp(self): + # Create test email address + self.email_address = EmailAddress.objects.create(name='test@example.com') + + # Create test group + self.group = Group.objects.create(name='Test Group') + self.group.contact_email = self.email_address + self.group.save() + + # Create test waiters + now = timezone.now() + old_confirmation = now - timezone.timedelta(days=settings.WAITING_CONFIRMATION_FREQUENCY + 1) + old_reminder = now - timezone.timedelta(days=settings.CONFIRMATION_REMINDER_FREQUENCY + 1) + + self.waiter1 = MemberWaitingList.objects.create( + prename='Test', + lastname='Waiter1', + birth_date=now.date(), + email='waiter1@example.com', + gender=DIVERSE, + last_wait_confirmation=old_confirmation, + last_reminder=old_reminder, + sent_reminders=0 + ) + + self.waiter2 = MemberWaitingList.objects.create( + prename='Test', + lastname='Waiter2', + birth_date=now.date(), + email='waiter2@example.com', + gender=DIVERSE, + last_wait_confirmation=old_confirmation, + last_reminder=old_reminder, + sent_reminders=settings.MAX_REMINDER_COUNT - 1 + ) + + # Create waiter that shouldn't receive reminder (too recent confirmation) + self.waiter3 = MemberWaitingList.objects.create( + prename='Test', + lastname='Waiter3', + birth_date=now.date(), + email='waiter3@example.com', + gender=DIVERSE, + last_wait_confirmation=now, + last_reminder=old_reminder, + sent_reminders=0 + ) + + # Create waiter that shouldn't receive reminder (max reminders reached) + self.waiter4 = MemberWaitingList.objects.create( + prename='Test', + lastname='Waiter4', + birth_date=now.date(), + email='waiter4@example.com', + gender=DIVERSE, + last_wait_confirmation=old_confirmation, + last_reminder=old_reminder, + sent_reminders=settings.MAX_REMINDER_COUNT + ) + + # Create test excursions + today = timezone.now().date() + tomorrow = today + timezone.timedelta(days=1) + + self.excursion_today_not_sent = Freizeit.objects.create( + name='Today Excursion 1', + date=timezone.now().replace(hour=10, minute=0, second=0, microsecond=0), + tour_type=GEMEINSCHAFTS_TOUR, + kilometers_traveled=10, + difficulty=1, + crisis_intervention_list_sent=False, + notification_crisis_intervention_list_sent=False + ) + + self.excursion_today_sent = Freizeit.objects.create( + name='Today Excursion 2', + date=timezone.now().replace(hour=14, minute=0, second=0, microsecond=0), + tour_type=GEMEINSCHAFTS_TOUR, + kilometers_traveled=10, + difficulty=1, + crisis_intervention_list_sent=True, + notification_crisis_intervention_list_sent=True + ) + + self.excursion_tomorrow_not_sent = Freizeit.objects.create( + name='Tomorrow Excursion 1', + date=(timezone.now() + timezone.timedelta(days=1)).replace(hour=10, minute=0, second=0, microsecond=0), + tour_type=GEMEINSCHAFTS_TOUR, + kilometers_traveled=10, + difficulty=1, + crisis_intervention_list_sent=False, + notification_crisis_intervention_list_sent=False + ) + + self.excursion_tomorrow_sent = Freizeit.objects.create( + name='Tomorrow Excursion 2', + date=(timezone.now() + timezone.timedelta(days=1)).replace(hour=14, minute=0, second=0, microsecond=0), + tour_type=GEMEINSCHAFTS_TOUR, + kilometers_traveled=10, + difficulty=1, + crisis_intervention_list_sent=True, + notification_crisis_intervention_list_sent=True + ) + + @patch.object(MemberWaitingList, 'ask_for_wait_confirmation') + def test_ask_for_waiting_confirmation(self, mock_ask): + """Test ask_for_waiting_confirmation task calls correct waiters.""" + result = ask_for_waiting_confirmation() + + # Should call ask_for_wait_confirmation for waiter1 and waiter2 only + self.assertEqual(result, 2) + self.assertEqual(mock_ask.call_count, 2) + + @patch.object(Freizeit, 'send_crisis_intervention_list') + def test_send_crisis_intervention_list(self, mock_send): + """Test send_crisis_intervention_list task calls correct excursions.""" + result = send_crisis_intervention_list() + + # Should call send_crisis_intervention_list for today's excursions that haven't been sent + self.assertEqual(result, 1) + self.assertEqual(mock_send.call_count, 1) + + @patch.object(Freizeit, 'notify_leaders_crisis_intervention_list') + def test_send_notification_crisis_intervention_list(self, mock_notify): + """Test send_notification_crisis_intervention_list task calls correct excursions.""" + result = send_notification_crisis_intervention_list() + + # Should call notify_leaders_crisis_intervention_list for tomorrow's excursions that haven't been sent + self.assertEqual(result, 1) + self.assertEqual(mock_notify.call_count, 1) diff --git a/jdav_web/members/tests/utils.py b/jdav_web/members/tests/utils.py index 39eaba5..9660aea 100644 --- a/jdav_web/members/tests/utils.py +++ b/jdav_web/members/tests/utils.py @@ -83,7 +83,8 @@ class BasicMemberTestCase(TestCase): """ def setUp(self): self.jl = Group.objects.create(name="Jugendleiter", year_from=0, year_to=0) - self.alp = Group.objects.create(name="Alpenfuechse", year_from=1900, year_to=2000) + self.alp = Group.objects.create(name="Alpenfuechse", year_from=1900, year_to=2000, + show_website=True) self.spiel = Group.objects.create(name="Spielkinder") self.fritz = Member.objects.create(prename="Fritz", lastname="Wulter", birth_date=timezone.now().date(), diff --git a/jdav_web/members/tests/views.py b/jdav_web/members/tests/views.py new file mode 100644 index 0000000..0276424 --- /dev/null +++ b/jdav_web/members/tests/views.py @@ -0,0 +1,110 @@ +from unittest import skip +from http import HTTPStatus +from django.test import TestCase, Client +from django.urls import reverse +from django.utils import timezone +from django.utils.translation import gettext as _ + +from mailer.models import EmailAddress +from ..models import Member, Group, InvitationToGroup, MemberWaitingList, DIVERSE + + +class ConfirmInvitationViewTestCase(TestCase): + def setUp(self): + self.client = Client() + + # Create an email address for the group + self.email_address = EmailAddress.objects.create(name='testmail') + + # Create a test group + self.group = Group.objects.create(name='Test Group') + self.group.contact_email = self.email_address + self.group.save() + + # Create a waiting list entry + self.waiter = MemberWaitingList.objects.create( + prename='Waiter', + lastname='User', + birth_date=timezone.now().date(), + email='waiter@example.com', + gender=DIVERSE, + wait_confirmation_key='test_wait_key', + wait_confirmation_key_expire=timezone.now() + timezone.timedelta(days=1) + ) + + # Create an invitation + self.invitation = InvitationToGroup.objects.create( + waiter=self.waiter, + group=self.group, + key='test_invitation_key', + date=timezone.now().date() + ) + + def test_confirm_invitation_get_valid_key(self): + """Test GET request with valid key shows invitation confirmation page.""" + url = reverse('members:confirm_invitation') + response = self.client.get(url, {'key': 'test_invitation_key'}) + self.assertEqual(response.status_code, HTTPStatus.OK) + self.assertContains(response, _('Confirm trial group meeting invitation')) + self.assertContains(response, self.group.name) + + def test_confirm_invitation_get_invalid_key(self): + """Test GET request with invalid key shows invalid confirmation page.""" + url = reverse('members:confirm_invitation') + + # no key + response = self.client.get(url) + self.assertEqual(response.status_code, HTTPStatus.OK) + self.assertContains(response, _('This invitation is invalid or expired.')) + + # invalid key + response = self.client.get(url, {'key': 'invalid_key'}) + self.assertEqual(response.status_code, HTTPStatus.OK) + self.assertContains(response, _('This invitation is invalid or expired.')) + + def test_confirm_invitation_get_rejected_invitation(self): + """Test GET request with rejected invitation shows invalid confirmation page.""" + self.invitation.rejected = True + self.invitation.save() + + url = reverse('members:confirm_invitation') + response = self.client.get(url, {'key': self.invitation.key}) + self.assertEqual(response.status_code, HTTPStatus.OK) + self.assertContains(response, _('This invitation is invalid or expired.')) + + def test_confirm_invitation_get_expired_invitation(self): + """Test GET request with expired invitation shows invalid confirmation page.""" + # Set invitation date to more than 30 days ago to make it expired + self.invitation.date = timezone.now().date() - timezone.timedelta(days=31) + self.invitation.save() + + url = reverse('members:confirm_invitation') + response = self.client.get(url, {'key': self.invitation.key}) + self.assertEqual(response.status_code, HTTPStatus.OK) + self.assertContains(response, _('This invitation is invalid or expired.')) + + def test_confirm_invitation_post_invalid_key(self): + """Test POST request with invalid key shows invalid confirmation page.""" + url = reverse('members:confirm_invitation') + + # no key + response = self.client.post(url) + self.assertEqual(response.status_code, HTTPStatus.OK) + self.assertContains(response, _('This invitation is invalid or expired.')) + + # invalid key + response = self.client.post(url, {'key': 'invalid_key'}) + self.assertEqual(response.status_code, HTTPStatus.OK) + self.assertContains(response, _('This invitation is invalid or expired.')) + + def test_confirm_invitation_post_valid_key(self): + """Test POST request with valid key confirms invitation and shows success page.""" + url = reverse('members:confirm_invitation') + response = self.client.post(url, {'key': self.invitation.key}) + self.assertEqual(response.status_code, HTTPStatus.OK) + self.assertContains(response, _('Invitation confirmed')) + self.assertContains(response, self.group.name) + + # Verify invitation was not marked as rejected (confirm() sets rejected=False) + self.invitation.refresh_from_db() + self.assertFalse(self.invitation.rejected) diff --git a/jdav_web/members/views.py b/jdav_web/members/views.py index a4789d6..14dd53f 100644 --- a/jdav_web/members/views.py +++ b/jdav_web/members/views.py @@ -9,6 +9,7 @@ from members.models import Member, RegistrationPassword, MemberUnconfirmedProxy, from django.urls import reverse from django.utils import timezone from django.conf import settings +from django.views.decorators.cache import never_cache from .pdf import render_tex, media_path @@ -505,6 +506,7 @@ def render_confirm_success(request, invitation): 'timeinfo': invitation.group.get_time_info()}) +@never_cache def confirm_invitation(request): if request.method == 'GET' and 'key' in request.GET: key = request.GET['key'] From 25ec55d731ac0aab38c46917c1100731f72f2138 Mon Sep 17 00:00:00 2001 From: Christian Merten Date: Mon, 18 Aug 2025 22:11:40 +0200 Subject: [PATCH 18/25] chore(members/tests): add remaining PDF test cases --- jdav_web/members/tests/basic.py | 77 ++++++++++++++++++++++++++++++++- 1 file changed, 76 insertions(+), 1 deletion(-) diff --git a/jdav_web/members/tests/basic.py b/jdav_web/members/tests/basic.py index 409779c..6d620f4 100644 --- a/jdav_web/members/tests/basic.py +++ b/jdav_web/members/tests/basic.py @@ -14,6 +14,11 @@ from django.conf import settings from django.urls import reverse from django import template from unittest import skip, mock +import os +from PIL import Image +from pypdf import PdfReader, PdfWriter, PageObject +from io import BytesIO +import tempfile from members.models import Member, Group, PermissionMember, PermissionGroup, Freizeit, GEMEINSCHAFTS_TOUR,\ MUSKELKRAFT_ANREISE, FUEHRUNGS_TOUR, AUSBILDUNGS_TOUR, OEFFENTLICHE_ANREISE,\ FAHRGEMEINSCHAFT_ANREISE,\ @@ -25,7 +30,7 @@ from members.admin import MemberWaitingListAdmin, MemberAdmin, FreizeitAdmin, Me MemberUnconfirmedAdmin, RegistrationFilter, FilteredMemberFieldMixin,\ MemberAdminForm, StatementOnListForm, KlettertreffAdmin, GroupAdmin,\ InvitationToGroupAdmin, AgeFilter, InvitedToGroupFilter -from members.pdf import fill_pdf_form, render_tex, media_path, serve_pdf, find_template, merge_pdfs +from members.pdf import fill_pdf_form, render_tex, media_path, serve_pdf, find_template, merge_pdfs, render_docx, pdf_add_attachments, scale_pdf_page_to_a4, scale_pdf_to_a4 from mailer.models import EmailAddress, Message from finance.models import Statement, Bill @@ -397,6 +402,76 @@ class PDFTestCase(TestCase): context = self.ex.v32_fields() self._test_fill_pdf('members/V32-1_Themenorientierte_Bildungsmassnahmen.pdf', context) + def test_render_docx_save_only(self): + """Test render_docx with save_only=True""" + context = dict(memberlist=self.ex, settings=settings, mode='basic') + fp = render_docx('Test DOCX', 'members/seminar_report.tex', context, save_only=True) + self.assertIsInstance(fp, str) + self.assertTrue(fp.endswith('.docx')) + + def test_pdf_add_attachments_with_image(self): + """Test pdf_add_attachments with non-PDF image files""" + # Create a simple test image + with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as tmp_file: + img = Image.new('RGB', (100, 100), color='red') + img.save(tmp_file.name, 'PNG') + tmp_file.flush() + + # Create a PDF writer and test adding the image + writer = PdfWriter() + blank_page = PageObject.create_blank_page(width=595, height=842) + writer.add_page(blank_page) + + # add image as attachment and verify page count + pdf_add_attachments(writer, [tmp_file.name]) + self.assertGreater(len(writer.pages), 1) + + # Clean up + os.unlink(tmp_file.name) + + def test_scale_pdf_page_to_a4(self): + """Test scale_pdf_page_to_a4 function""" + # Create a test page with different dimensions + original_page = PageObject.create_blank_page(width=200, height=300) + scaled_page = scale_pdf_page_to_a4(original_page) + + # A4 dimensions are 595x842 + self.assertEqual(float(scaled_page.mediabox.width), 595.0) + self.assertEqual(float(scaled_page.mediabox.height), 842.0) + + def test_scale_pdf_to_a4(self): + """Test scale_pdf_to_a4 function""" + # Create a simple PDF with multiple pages of different sizes + original_pdf = PdfWriter() + original_pdf.add_page(PageObject.create_blank_page(width=200, height=300)) + original_pdf.add_page(PageObject.create_blank_page(width=400, height=600)) + + # Write to BytesIO to create a readable PDF + pdf_io = BytesIO() + original_pdf.write(pdf_io) + pdf_io.seek(0) + + # Read it back and scale + pdf_reader = PdfReader(pdf_io) + scaled_pdf = scale_pdf_to_a4(pdf_reader) + + # All pages should be A4 size (595x842) + for page in scaled_pdf.pages: + self.assertEqual(float(page.mediabox.width), 595.0) + self.assertEqual(float(page.mediabox.height), 842.0) + + def test_merge_pdfs_serve(self): + """Test merge_pdfs with save_only=False""" + # First create two PDF files to merge + context = dict(memberlist=self.ex, settings=settings, mode='basic') + fp1 = render_tex('Test PDF 1', 'members/seminar_report.tex', context, save_only=True) + fp2 = render_tex('Test PDF 2', 'members/seminar_report.tex', context, save_only=True) + + # Test merge with save_only=False (should return HttpResponse) + response = merge_pdfs('Merged PDF', [fp1, fp2], save_only=False) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.headers['Content-Type'], 'application/pdf') + class AdminTestCase(TestCase): def setUp(self, model, admin): From b613dc70c26bd6d4b4fa20864391cf238e366baa Mon Sep 17 00:00:00 2001 From: Christian Merten Date: Mon, 18 Aug 2025 23:07:29 +0200 Subject: [PATCH 19/25] chore(finance/tests): add rules tests --- jdav_web/finance/tests/__init__.py | 1 + jdav_web/finance/tests/rules.py | 102 +++++++++++++++++++++++++++++ 2 files changed, 103 insertions(+) create mode 100644 jdav_web/finance/tests/rules.py diff --git a/jdav_web/finance/tests/__init__.py b/jdav_web/finance/tests/__init__.py index 4754139..41989f9 100644 --- a/jdav_web/finance/tests/__init__.py +++ b/jdav_web/finance/tests/__init__.py @@ -1,2 +1,3 @@ from .admin import * from .models import * +from .rules import * diff --git a/jdav_web/finance/tests/rules.py b/jdav_web/finance/tests/rules.py new file mode 100644 index 0000000..ce5fa40 --- /dev/null +++ b/jdav_web/finance/tests/rules.py @@ -0,0 +1,102 @@ +from django.test import TestCase +from django.utils import timezone +from django.conf import settings +from django.contrib.auth.models import User +from unittest.mock import Mock +from finance.rules import is_creator, not_submitted, leads_excursion +from finance.models import Statement, Ledger +from members.models import Member, Group, Freizeit, GEMEINSCHAFTS_TOUR, MUSKELKRAFT_ANREISE, MALE, FEMALE + + +class FinanceRulesTestCase(TestCase): + def setUp(self): + self.group = Group.objects.create(name="Test Group") + self.ledger = Ledger.objects.create(name="Test Ledger") + + self.user1 = User.objects.create_user(username="alice", password="test123") + self.member1 = Member.objects.create( + prename="Alice", lastname="Smith", birth_date=timezone.now().date(), + email=settings.TEST_MAIL, gender=FEMALE, user=self.user1 + ) + self.member1.group.add(self.group) + + self.user2 = User.objects.create_user(username="bob", password="test123") + self.member2 = Member.objects.create( + prename="Bob", lastname="Jones", birth_date=timezone.now().date(), + email=settings.TEST_MAIL, gender=MALE, user=self.user2 + ) + self.member2.group.add(self.group) + + self.freizeit = Freizeit.objects.create( + name="Test Excursion", + kilometers_traveled=100, + tour_type=GEMEINSCHAFTS_TOUR, + tour_approach=MUSKELKRAFT_ANREISE, + difficulty=2 + ) + self.freizeit.jugendleiter.add(self.member1) + + self.statement = Statement.objects.create( + short_description="Test Statement", + explanation="Test explanation", + night_cost=27, + created_by=self.member1, + excursion=self.freizeit + ) + self.statement.allowance_to.add(self.member1) + + def test_is_creator_true(self): + """Test is_creator predicate returns True when user created the statement""" + self.assertTrue(is_creator(self.user1, self.statement)) + self.assertFalse(is_creator(self.user2, self.statement)) + + def test_not_submitted_statement(self): + """Test not_submitted predicate returns True when statement is not submitted""" + self.statement.submitted = False + self.assertTrue(not_submitted(self.user1, self.statement)) + self.statement.submitted = True + self.assertFalse(not_submitted(self.user1, self.statement)) + + def test_not_submitted_freizeit_with_statement(self): + """Test not_submitted predicate with Freizeit having unsubmitted statement""" + self.freizeit.statement = self.statement + self.statement.submitted = False + self.assertTrue(not_submitted(self.user1, self.freizeit)) + + def test_not_submitted_freizeit_without_statement(self): + """Test not_submitted predicate with Freizeit having no statement attribute""" + # Create a mock Freizeit that truly doesn't have the statement attribute + mock_freizeit = Mock(spec=Freizeit) + # Remove the statement attribute entirely + if hasattr(mock_freizeit, 'statement'): + delattr(mock_freizeit, 'statement') + self.assertTrue(not_submitted(self.user1, mock_freizeit)) + + def test_leads_excursion_freizeit_user_is_leader(self): + """Test leads_excursion predicate returns True when user leads the Freizeit""" + self.assertTrue(leads_excursion(self.user1, self.freizeit)) + self.assertFalse(leads_excursion(self.user2, self.freizeit)) + + def test_leads_excursion_statement_with_excursion(self): + """Test leads_excursion predicate with statement having excursion led by user""" + result = leads_excursion(self.user1, self.statement) + self.assertTrue(result) + + def test_leads_excursion_statement_no_excursion_attribute(self): + """Test leads_excursion predicate with statement having no excursion attribute""" + mock_statement = Mock() + del mock_statement.excursion + result = leads_excursion(self.user1, mock_statement) + self.assertFalse(result) + + def test_leads_excursion_statement_excursion_is_none(self): + """Test leads_excursion predicate with statement having None excursion""" + statement_no_excursion = Statement.objects.create( + short_description="Test Statement No Excursion", + explanation="Test explanation", + night_cost=27, + created_by=self.member1, + excursion=None + ) + result = leads_excursion(self.user1, statement_no_excursion) + self.assertFalse(result) From afedf74f8fdea382f06b950bc5c9aff59179278e Mon Sep 17 00:00:00 2001 From: Christian Merten Date: Tue, 19 Aug 2025 00:07:14 +0200 Subject: [PATCH 20/25] chore(tests/material): add models and admin tests --- jdav_web/material/tests.py | 130 ++++++++++++++++++++++++++++++++++++- 1 file changed, 127 insertions(+), 3 deletions(-) diff --git a/jdav_web/material/tests.py b/jdav_web/material/tests.py index 53ee4ec..c736ce6 100644 --- a/jdav_web/material/tests.py +++ b/jdav_web/material/tests.py @@ -1,8 +1,10 @@ -from django.test import TestCase +from django.test import TestCase, RequestFactory from django.utils import timezone -from datetime import date +from datetime import date, datetime from decimal import Decimal -from material.models import MaterialCategory, MaterialPart, Ownership +from unittest.mock import Mock +from material.models import MaterialCategory, MaterialPart, Ownership, yearsago +from material.admin import NotTooOldFilter, MaterialAdmin from members.models import Member, MALE, FEMALE, DIVERSE @@ -75,6 +77,37 @@ class MaterialPartTestCase(TestCase): self.assertTrue(hasattr(field, 'verbose_name')) self.assertIsNotNone(field.verbose_name) + def test_admin_thumbnail_with_photo(self): + """Test admin_thumbnail when photo exists""" + mock_photo = Mock() + mock_photo.url = "/media/test.jpg" + self.material_part.photo = mock_photo + result = self.material_part.admin_thumbnail() + self.assertIn("/media/test.jpg", result) + self.assertIn(" Date: Tue, 19 Aug 2025 00:37:03 +0200 Subject: [PATCH 21/25] chore(*): remove stub files --- jdav_web/contrib/views.py | 3 --- jdav_web/finance/views.py | 3 --- jdav_web/material/views.py | 3 --- 3 files changed, 9 deletions(-) delete mode 100644 jdav_web/contrib/views.py delete mode 100644 jdav_web/finance/views.py delete mode 100644 jdav_web/material/views.py diff --git a/jdav_web/contrib/views.py b/jdav_web/contrib/views.py deleted file mode 100644 index 91ea44a..0000000 --- a/jdav_web/contrib/views.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.shortcuts import render - -# Create your views here. diff --git a/jdav_web/finance/views.py b/jdav_web/finance/views.py deleted file mode 100644 index 91ea44a..0000000 --- a/jdav_web/finance/views.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.shortcuts import render - -# Create your views here. diff --git a/jdav_web/material/views.py b/jdav_web/material/views.py deleted file mode 100644 index 91ea44a..0000000 --- a/jdav_web/material/views.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.shortcuts import render - -# Create your views here. From 996914dc77c1ccc1d46de02e0431da1ea73ca8ac Mon Sep 17 00:00:00 2001 From: Christian Merten Date: Tue, 19 Aug 2025 00:37:40 +0200 Subject: [PATCH 22/25] chore(tests): add mailer and logindata tests --- jdav_web/logindata/tests/__init__.py | 3 +- jdav_web/logindata/tests/oauth.py | 47 ++++++++++++++++++++++++++++ jdav_web/mailer/tests/__init__.py | 1 + jdav_web/mailer/tests/rules.py | 31 ++++++++++++++++++ 4 files changed, 81 insertions(+), 1 deletion(-) create mode 100644 jdav_web/logindata/tests/oauth.py create mode 100644 jdav_web/mailer/tests/rules.py diff --git a/jdav_web/logindata/tests/__init__.py b/jdav_web/logindata/tests/__init__.py index 3e3023e..e39bc3b 100644 --- a/jdav_web/logindata/tests/__init__.py +++ b/jdav_web/logindata/tests/__init__.py @@ -1 +1,2 @@ -from .views import * \ No newline at end of file +from .views import * +from .oauth import * \ No newline at end of file diff --git a/jdav_web/logindata/tests/oauth.py b/jdav_web/logindata/tests/oauth.py new file mode 100644 index 0000000..9a414a1 --- /dev/null +++ b/jdav_web/logindata/tests/oauth.py @@ -0,0 +1,47 @@ +from django.test import TestCase +from django.contrib.auth.models import User +from django.conf import settings +from unittest.mock import Mock +from logindata.oauth import CustomOAuth2Validator +from members.models import Member, MALE + + +class CustomOAuth2ValidatorTestCase(TestCase): + def setUp(self): + self.validator = CustomOAuth2Validator() + + # Create user with member + self.user_with_member = User.objects.create_user(username="alice", password="test123") + self.member = Member.objects.create( + prename="Alice", lastname="Smith", birth_date="1990-01-01", + email=settings.TEST_MAIL, gender=MALE, user=self.user_with_member + ) + + # Create user without member + self.user_without_member = User.objects.create_user(username="bob", password="test123") + + def test_get_additional_claims_with_member(self): + """Test get_additional_claims when user has a member""" + request = Mock() + request.user = self.user_with_member + + result = self.validator.get_additional_claims(request) + + self.assertEqual(result['email'], settings.TEST_MAIL) + self.assertEqual(result['preferred_username'], 'alice') + + def test_get_additional_claims_without_member(self): + """Test get_additional_claims when user has no member""" + # ensure branch coverage, not possible under standard scenarios + request = Mock() + request.user = Mock() + request.user.member = None + self.assertEqual(len(self.validator.get_additional_claims(request)), 1) + + request = Mock() + request.user = self.user_without_member + + # The method will raise RelatedObjectDoesNotExist, which means the code + # should use hasattr or try/except. For now, test that it raises. + with self.assertRaises(User.member.RelatedObjectDoesNotExist): + self.validator.get_additional_claims(request) diff --git a/jdav_web/mailer/tests/__init__.py b/jdav_web/mailer/tests/__init__.py index a80e178..0d2e866 100644 --- a/jdav_web/mailer/tests/__init__.py +++ b/jdav_web/mailer/tests/__init__.py @@ -1,3 +1,4 @@ from .models import * from .admin import * from .views import * +from .rules import * diff --git a/jdav_web/mailer/tests/rules.py b/jdav_web/mailer/tests/rules.py new file mode 100644 index 0000000..74eb958 --- /dev/null +++ b/jdav_web/mailer/tests/rules.py @@ -0,0 +1,31 @@ +from django.test import TestCase +from django.conf import settings +from django.contrib.auth.models import User +from mailer.rules import is_creator +from mailer.models import Message +from members.models import Member, MALE + + +class MailerRulesTestCase(TestCase): + def setUp(self): + self.user1 = User.objects.create_user(username="alice", password="test123") + self.member1 = Member.objects.create( + prename="Alice", lastname="Smith", birth_date="1990-01-01", + email=settings.TEST_MAIL, gender=MALE, user=self.user1 + ) + + self.message = Message.objects.create( + subject="Test Message", + content="Test content", + created_by=self.member1 + ) + + def test_is_creator_returns_true_when_user_created_message(self): + """Test is_creator predicate returns True when user created the message""" + result = is_creator(self.user1, self.message) + self.assertTrue(result) + + def test_is_creator_returns_false_when_message_is_none(self): + """Test is_creator predicate returns False when message is None""" + result = is_creator(self.user1, None) + self.assertFalse(result) From 52f02099128bfbfabe626f0689f999ac0feeaa80 Mon Sep 17 00:00:00 2001 From: Christian Merten Date: Tue, 19 Aug 2025 00:51:33 +0200 Subject: [PATCH 23/25] chore(*): ignore some files from tests --- jdav_web/.coveragerc | 1 + jdav_web/jdav_web/celery.py | 2 +- jdav_web/members/csv.py | 6 +++--- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/jdav_web/.coveragerc b/jdav_web/.coveragerc index 46d095d..db9f6fb 100644 --- a/jdav_web/.coveragerc +++ b/jdav_web/.coveragerc @@ -6,3 +6,4 @@ omit = ./jet/* manage.py + jdav_web/wsgi.py diff --git a/jdav_web/jdav_web/celery.py b/jdav_web/jdav_web/celery.py index 6cff3ef..04cc37c 100644 --- a/jdav_web/jdav_web/celery.py +++ b/jdav_web/jdav_web/celery.py @@ -11,4 +11,4 @@ app.config_from_object('django.conf:settings') app.autodiscover_tasks() if __name__ == '__main__': - app.start() + app.start() # pragma: no cover diff --git a/jdav_web/members/csv.py b/jdav_web/members/csv.py index 3fec6c8..404a3f6 100644 --- a/jdav_web/members/csv.py +++ b/jdav_web/members/csv.py @@ -1,6 +1,6 @@ -from .models import * -import re -import csv +from .models import * # pragma: no cover +import re # pragma: no cover +import csv # pragma: no cover def import_from_csv(path, omit_groupless=True): # pragma: no cover From f58a7dc4b6d2a19887f5112ee28780ee3ac39283 Mon Sep 17 00:00:00 2001 From: Christian Merten Date: Tue, 19 Aug 2025 23:03:56 +0200 Subject: [PATCH 24/25] chore(*/tests): various tests --- jdav_web/contrib/tests.py | 41 +++++++++++++++++++++++++++++-- jdav_web/logindata/tests/views.py | 7 ++++++ jdav_web/mailer/tests/models.py | 18 +++++++++++++- jdav_web/members/tests/basic.py | 7 ++++++ jdav_web/startpage/tests.py | 16 ++++++++++++ 5 files changed, 86 insertions(+), 3 deletions(-) diff --git a/jdav_web/contrib/tests.py b/jdav_web/contrib/tests.py index 41e3726..99493e1 100644 --- a/jdav_web/contrib/tests.py +++ b/jdav_web/contrib/tests.py @@ -1,7 +1,13 @@ from django.test import TestCase from django.contrib.auth import get_user_model +from django.contrib import admin +from django.db import models +from django.test import RequestFactory +from unittest.mock import Mock +from rules.contrib.models import RulesModelMixin, RulesModelBase from contrib.models import CommonModel from contrib.rules import has_global_perm +from contrib.admin import CommonAdminMixin User = get_user_model() @@ -20,8 +26,6 @@ class CommonModelTestCase(TestCase): # Test that CommonModel has the expected functionality # Since it's abstract, we can't instantiate it directly # but we can check its metaclass and mixins - from rules.contrib.models import RulesModelMixin, RulesModelBase - self.assertTrue(issubclass(CommonModel, RulesModelMixin)) self.assertEqual(CommonModel.__class__, RulesModelBase) @@ -54,3 +58,36 @@ class GlobalPermissionRulesTestCase(TestCase): predicate = has_global_perm('auth.add_user') result = predicate(self.user, None) self.assertFalse(result) + + +class CommonAdminMixinTestCase(TestCase): + def setUp(self): + self.user = User.objects.create_user(username='testuser', password='testpass') + + def test_formfield_for_dbfield_with_formfield_overrides(self): + """Test formfield_for_dbfield when db_field class is in formfield_overrides""" + # Create a test admin instance that inherits from Django's ModelAdmin + class TestAdmin(CommonAdminMixin, admin.ModelAdmin): + formfield_overrides = { + models.ForeignKey: {'widget': Mock()} + } + + # Create a mock model to use with the admin + class TestModel: + _meta = Mock() + _meta.app_label = 'test' + + admin_instance = TestAdmin(TestModel, admin.site) + + # Create a mock ForeignKey field to trigger the missing line 147 + db_field = models.ForeignKey(User, on_delete=models.CASCADE) + + # Create a test request + request = RequestFactory().get('/') + request.user = self.user + + # Call the method to test formfield_overrides usage + result = admin_instance.formfield_for_dbfield(db_field, request, help_text='Test help text') + + # Verify that the formfield_overrides were used + self.assertIsNotNone(result) diff --git a/jdav_web/logindata/tests/views.py b/jdav_web/logindata/tests/views.py index 95c8200..00e22d2 100644 --- a/jdav_web/logindata/tests/views.py +++ b/jdav_web/logindata/tests/views.py @@ -9,6 +9,13 @@ from members.models import Member, DIVERSE from ..models import RegistrationPassword, initial_user_setup +class RegistrationPasswordTestCase(TestCase): + def test_str_method(self): + """Test RegistrationPassword __str__ method returns password""" + reg_password = RegistrationPassword.objects.create(password="test123") + self.assertEqual(str(reg_password), "test123") + + class RegisterViewTestCase(TestCase): def setUp(self): self.client = Client() diff --git a/jdav_web/mailer/tests/models.py b/jdav_web/mailer/tests/models.py index ded8fad..feaed68 100644 --- a/jdav_web/mailer/tests/models.py +++ b/jdav_web/mailer/tests/models.py @@ -124,7 +124,7 @@ class MessageTestCase(BasicMailerTestCase): # Verify the message was not marked as sent self.message.refresh_from_db() self.assertFalse(self.message.sent) - # Note: The submit method always returns SENT due to line 190 in the code + # Note: The submit method always returns SENT when an exception occurs self.assertEqual(result, SENT) @mock.patch('mailer.models.send') @@ -236,6 +236,22 @@ class MessageTestCase(BasicMailerTestCase): with self.assertRaises(Attachment.DoesNotExist): attachment.refresh_from_db() + @mock.patch('mailer.models.send') + def test_submit_with_association_email_enabled(self, mock_send): + """Test submit method when SEND_FROM_ASSOCIATION_EMAIL is True and sender has association_email""" + mock_send.return_value = SENT + + # Mock settings to enable association email sending + with mock.patch.object(settings, 'SEND_FROM_ASSOCIATION_EMAIL', True): + result = self.message.submit(sender=self.sender) + + # Check that send was called with sender's association email + self.assertTrue(mock_send.called) + call_args = mock_send.call_args + from_addr = call_args[0][2] # from_addr is the 3rd positional argument + expected_from = f"{self.sender.name} <{self.sender.association_email}>" + self.assertEqual(from_addr, expected_from) + class AttachmentTestCase(BasicMailerTestCase): def setUp(self): diff --git a/jdav_web/members/tests/basic.py b/jdav_web/members/tests/basic.py index 6d620f4..bb9b280 100644 --- a/jdav_web/members/tests/basic.py +++ b/jdav_web/members/tests/basic.py @@ -31,6 +31,7 @@ from members.admin import MemberWaitingListAdmin, MemberAdmin, FreizeitAdmin, Me MemberAdminForm, StatementOnListForm, KlettertreffAdmin, GroupAdmin,\ InvitationToGroupAdmin, AgeFilter, InvitedToGroupFilter from members.pdf import fill_pdf_form, render_tex, media_path, serve_pdf, find_template, merge_pdfs, render_docx, pdf_add_attachments, scale_pdf_page_to_a4, scale_pdf_to_a4 +from members.excel import generate_ljp_vbk from mailer.models import EmailAddress, Message from finance.models import Statement, Bill @@ -942,6 +943,12 @@ class FreizeitTestCase(BasicMemberTestCase): self.ex.end = timezone.datetime(2000, 1, 1, 12, 0, 0) self.assertEqual(self.ex.duration, 1) + def test_generate_ljp_vbk_no_proposal_raises_error(self): + """Test generate_ljp_vbk raises ValueError when excursion has no LJP proposal""" + with self.assertRaises(ValueError) as cm: + generate_ljp_vbk(self.ex) + self.assertIn("Excursion has no LJP proposal", str(cm.exception)) + class PDFActionMixin: def _test_pdf(self, name, pk, model='freizeit', invalid=False, username='superuser', post_data=None): diff --git a/jdav_web/startpage/tests.py b/jdav_web/startpage/tests.py index ef81f7d..94ab953 100644 --- a/jdav_web/startpage/tests.py +++ b/jdav_web/startpage/tests.py @@ -4,8 +4,11 @@ from django.conf import settings from django.templatetags.static import static from django.utils import timezone from django.core.files.uploadedfile import SimpleUploadedFile +from unittest import mock +from importlib import reload from members.models import Member, Group, DIVERSE +from startpage import urls from .models import Post, Section, Image @@ -139,3 +142,16 @@ class ViewTestCase(BasicTestCase): url = img.f.url response = c.get('/de' + url) self.assertEqual(response.status_code, 200, 'Images on posts should be visible without login.') + + def test_urlpatterns_with_redirect_url(self): + """Test URL patterns when STARTPAGE_REDIRECT_URL is not empty""" + + # Mock settings to have a non-empty STARTPAGE_REDIRECT_URL + with mock.patch.object(settings, 'STARTPAGE_REDIRECT_URL', 'https://example.com'): + # Reload the urls module to trigger the conditional urlpatterns creation + reload(urls) + + # Check that urlpatterns contains the redirect view + url_names = [pattern.name for pattern in urls.urlpatterns if hasattr(pattern, 'name')] + self.assertIn('index', url_names) + self.assertEqual(len(urls.urlpatterns), 2) # Should have index and impressum only From a75208b41c5f2a941871b62797b50b8781fc2447 Mon Sep 17 00:00:00 2001 From: "marius.klein" Date: Mon, 25 Aug 2025 23:05:18 +0200 Subject: [PATCH 25/25] feat(members): add group meeting checklist generation (#154) Add an action to generate checklists for group meetings. These checklists can be used for documentation and for simplifying the check-in procedure in climbing gyms. Co-authored-by: mariusrklein <47218379+mariusrklein@users.noreply.github.com> Reviewed-on: https://git.jdav-hd.merten.dev/digitales/kompass/pulls/154 Co-authored-by: marius.klein Co-committed-by: marius.klein --- jdav_web/jdav_web/settings/local.py | 8 +++ jdav_web/locale/de/LC_MESSAGES/django.po | 6 +- jdav_web/members/admin.py | 32 +++++++-- .../members/locale/de/LC_MESSAGES/django.po | 9 +++ .../migrations/0042_member_ticket_no.py | 18 +++++ jdav_web/members/models.py | 13 ++++ .../templates/members/group_checklist.tex | 65 +++++++++++++++++++ jdav_web/members/templatetags/tex_extras.py | 22 +++++++ .../admin/members/group/change_list.html | 1 + jdav_web/utils.py | 10 ++- 10 files changed, 178 insertions(+), 6 deletions(-) create mode 100644 jdav_web/members/migrations/0042_member_ticket_no.py create mode 100644 jdav_web/members/templates/members/group_checklist.tex diff --git a/jdav_web/jdav_web/settings/local.py b/jdav_web/jdav_web/settings/local.py index 71e260c..f60cb8b 100644 --- a/jdav_web/jdav_web/settings/local.py +++ b/jdav_web/jdav_web/settings/local.py @@ -51,6 +51,14 @@ SEND_FROM_ASSOCIATION_EMAIL = get_var('misc', 'send_from_association_email', def # domain for association email and generated urls DOMAIN = get_var('misc', 'domain', default='example.org') +GROUP_CHECKLIST_N_WEEKS = get_var('misc', 'group_checklist_n_weeks', default=18) +GROUP_CHECKLIST_N_MEMBERS = get_var('misc', 'group_checklist_n_members', default=20) +GROUP_CHECKLIST_TEXT = get_var('misc', 'group_checklist_text', + default="""Anwesende Jugendleitende und Teilnehmende werden mit einem +Kreuz ($\\times$) markiert und die ausgefüllte Liste zum Anfang der Gruppenstunde an der Kasse +abgegeben. Zum Ende wird sie wieder abgeholt. Wenn die Punkte auf einer Karte fast aufgebraucht +sind, notiert die Kasse die verbliebenen Eintritte (3, 2, 1) unter dem Kreuz.""") + # finance ALLOWANCE_PER_DAY = get_var('finance', 'allowance_per_day', default=22) diff --git a/jdav_web/locale/de/LC_MESSAGES/django.po b/jdav_web/locale/de/LC_MESSAGES/django.po index 1adcf4e..7caacfe 100644 --- a/jdav_web/locale/de/LC_MESSAGES/django.po +++ b/jdav_web/locale/de/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-04-06 19:10+0200\n" +"POT-Creation-Date: 2025-04-15 23:05+0200\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -276,6 +276,10 @@ msgstr "Kostenübersicht" msgid "Generate group overview" msgstr "Gruppenübersicht erstellen" +#: templates/admin/members/group/change_list.html +msgid "Generate group checklist" +msgstr "Gruppencheckliste erstellen" + #: templates/admin/members/member/change_form_object_tools.html msgid "Invite as user" msgstr "Als Kompassbenutzer*in einladen" diff --git a/jdav_web/members/admin.py b/jdav_web/members/admin.py index 9cf41bf..28c8edf 100644 --- a/jdav_web/members/admin.py +++ b/jdav_web/members/admin.py @@ -30,6 +30,7 @@ from django.shortcuts import render from django.core.exceptions import PermissionDenied, ValidationError from .pdf import render_tex, fill_pdf_form, merge_pdfs, serve_pdf, render_docx from .excel import generate_group_overview, generate_ljp_vbk +from .models import WEEKDAYS from contrib.admin import CommonAdminInlineMixin, CommonAdminMixin @@ -44,7 +45,7 @@ from .models import (Member, Group, Freizeit, MemberNoteList, NewMemberOnList, K from finance.models import Statement, BillOnExcursionProxy from mailer.mailutils import send as send_mail, get_echo_link from django.conf import settings -from utils import get_member, RestrictedFileField +from utils import get_member, RestrictedFileField, mondays_until_nth from schwifty import IBAN from .pdf import media_path, media_dir @@ -195,7 +196,6 @@ class MemberAdmin(CommonAdminMixin, admin.ModelAdmin): ('join_date', 'leave_date'), 'comments', 'legal_guardians', - 'dav_badge_no', 'active', 'echoed', 'user', ] @@ -213,8 +213,8 @@ class MemberAdmin(CommonAdminMixin, admin.ModelAdmin): ), (_("Others"), { - 'fields': ['allergies', 'tetanus_vaccination', 'medication', 'photos_may_be_taken', - 'may_cancel_appointment_independently'] + 'fields': ['dav_badge_no', 'ticket_no', 'allergies', 'tetanus_vaccination', + 'medication', 'photos_may_be_taken','may_cancel_appointment_independently'] } ), (_("Organizational"), @@ -854,6 +854,8 @@ class GroupAdmin(CommonAdminMixin, admin.ModelAdmin): def action_view(self, request): if "group_overview" in request.POST: return self.group_overview(request) + elif "group_checklist" in request.POST: + return self.group_checklist(request) def group_overview(self, request): @@ -867,6 +869,28 @@ class GroupAdmin(CommonAdminMixin, admin.ModelAdmin): response = serve_media(filename=filename, content_type='application/xlsx') return response + + def group_checklist(self, request): + + if not request.user.has_perm('members.view_group'): + messages.error(request, + _("You are not allowed to create a group checklist.")) + return HttpResponseRedirect(reverse('admin:%s_%s_changelist' % (self.opts.app_label, self.opts.model_name))) + + ensure_media_dir() + n_weeks = settings.GROUP_CHECKLIST_N_WEEKS + n_members = settings.GROUP_CHECKLIST_N_MEMBERS + + context = { + 'groups': self.model.objects.filter(show_website=True), + 'settings': settings, + 'week_range': range(n_weeks), + 'member_range': range(n_members), + 'dates': mondays_until_nth(n_weeks), + 'weekdays': [long for i, long in WEEKDAYS], + 'header_text': settings.GROUP_CHECKLIST_TEXT, + } + return render_tex(f"Gruppen-Checkliste", 'members/group_checklist.tex', context) class ActivityCategoryAdmin(admin.ModelAdmin): diff --git a/jdav_web/members/locale/de/LC_MESSAGES/django.po b/jdav_web/members/locale/de/LC_MESSAGES/django.po index 3344f9c..d231df7 100644 --- a/jdav_web/members/locale/de/LC_MESSAGES/django.po +++ b/jdav_web/members/locale/de/LC_MESSAGES/django.po @@ -301,6 +301,11 @@ msgid "You are not allowed to create a group overview." msgstr "" "Du hast nicht die notwendigen Rechte um eine Gruppenübersicht zu erstellen." +#: members/admin.py +msgid "You are not allowed to create a group checklist." +msgstr "" +"Du hast nicht die notwendigen Rechte um eine Gruppencheckliste zu erstellen." + #: members/admin.py msgid "Difficulty" msgstr "Schwierigkeit" @@ -688,6 +693,10 @@ msgstr "Hat Freikarte für Kletterhalle" msgid "DAV badge number" msgstr "DAV Mitgliedsnummer" +#: members/models.py +msgid "entrance ticket number" +msgstr "Eintrittskarten Nummer" + #: members/models.py msgid "Knows how to swim" msgstr "Kann schwimmen" diff --git a/jdav_web/members/migrations/0042_member_ticket_no.py b/jdav_web/members/migrations/0042_member_ticket_no.py new file mode 100644 index 0000000..c1c28fb --- /dev/null +++ b/jdav_web/members/migrations/0042_member_ticket_no.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.20 on 2025-06-22 11:31 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('members', '0041_freizeit_crisis_intervention_list_sent_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='member', + name='ticket_no', + field=models.CharField(blank=True, default='', max_length=20, verbose_name='entrance ticket number'), + ), + ] diff --git a/jdav_web/members/models.py b/jdav_web/members/models.py index 0d8fe64..660eac0 100644 --- a/jdav_web/members/models.py +++ b/jdav_web/members/models.py @@ -104,6 +104,11 @@ class Group(models.Model): class Meta: verbose_name = _('group') verbose_name_plural = _('groups') + + @property + def sorted_members(self): + """Returns the members of this group sorted by their last name.""" + return self.member_set.all().order_by('lastname') def has_time_info(self): # return if the group has all relevant time slot information filled @@ -318,6 +323,9 @@ class Member(Person): has_key = models.BooleanField(_('Has key'), default=False) has_free_ticket_gym = models.BooleanField(_('Has a free ticket for the climbing gym'), default=False) dav_badge_no = models.CharField(max_length=20, verbose_name=_('DAV badge number'), default='', blank=True) + + # use this to store a climbing gym customer or membership id, used to print on meeting checklists + ticket_no = models.CharField(max_length=20, verbose_name=_('entrance ticket number'), default='', blank=True) swimming_badge = models.BooleanField(verbose_name=_('Knows how to swim'), default=False) climbing_badge = models.CharField(max_length=100, verbose_name=_('Climbing badge'), default='', blank=True) alpine_experience = models.TextField(verbose_name=_('Alpine experience'), default='', blank=True) @@ -379,6 +387,11 @@ class Member(Person): def place(self): """Returning the whole place (plz + town)""" return "{0} {1}".format(self.plz, self.town) + + @property + def ticket_tag(self): + """Returning the ticket number stripped of strings and spaces""" + return "{" + ''.join(re.findall(r'\d', self.ticket_no)) + "}" @property def iban_valid(self): diff --git a/jdav_web/members/templates/members/group_checklist.tex b/jdav_web/members/templates/members/group_checklist.tex new file mode 100644 index 0000000..81ff72d --- /dev/null +++ b/jdav_web/members/templates/members/group_checklist.tex @@ -0,0 +1,65 @@ +{% extends "members/tex_base.tex" %} +{% load static common tex_extras %} + +{% block headline %}{% endblock %} +{% block contact %}{% endblock %} + +{% block extra-preamble %} +\usepackage{rotating} +\usepackage[code=Code39,X=.48mm,ratio=3.5,H=0.5cm]{makebarcode} +\geometry{reset,margin=1cm, bottom=1.5cm} +\renewcommand{\arraystretch}{1} +{% endblock %} + +{% block content %} +{% settings_value 'DEFAULT_STATIC_PATH' as static_root %} + +{% for group in groups %} +\picpos{2.5cm}{16cm}{-0.4cm}{% +{{ static_root }}/general/img/dav_logo_sektion.png% +} +% HEADLINE + +{\noindent\Large{Gruppenliste {{ group.name }} }}\\[1mm] +{% if group.has_time_info %} \noindent {{ weekdays|index:group.weekday|esc_all }}, {{ group.start_time }} - {{ group.end_time }} Uhr\\ {% endif %} +\noindent {{ header_text }} +\begin{table}[H] + \centering +%\begin{tabularx}{\textwidth}{lYY|l|l|l|l|l|l|l|l|l|l|l|l|l|l|l|l|l} +\begin{tabularx}{\textwidth}{X{% for i in week_range %}|l{% endfor%}} +\toprule + \textbf{Name} {% for i in week_range %} + & \begin{sideways} {{ dates|index:i|add:group.weekday|date_vs }} \end{sideways} +{% endfor %} \\ + + {% for j in member_range %} + {% with m=group.sorted_members|index:j %} + {% with codelength=m.ticket_tag|length %} + \midrule + \begin{tabular}{@{}l} + {% if codelength > 2 %} + \barcode[ + X=\dimexpr 3.5mm / \numexpr {{ codelength }} \relax \relax + ]{{ m.ticket_tag }} + {% else %} + \rule{0pt}{5mm} + {% endif %} + \vspace{-0.8ex} \\ + {\small {{ j|plus:1 }} {% if m in group.leiters.all %}\textbf{JL}{% endif %} + {{ m.name|esc_all }} {% if codelength > 2 %} - {{ m.ticket_tag }}{% endif %} + \vspace{-3ex} } + \end{tabular} + + {% for i in week_range %} & {% endfor %}\\ + {% endwith %} + {% endwith %} + {% endfor %} + + \bottomrule +\end{tabularx} +\end{table} + +\clearpage +{% endfor %} + +{% endblock content %} diff --git a/jdav_web/members/templatetags/tex_extras.py b/jdav_web/members/templatetags/tex_extras.py index 02169e7..cdc03a3 100644 --- a/jdav_web/members/templatetags/tex_extras.py +++ b/jdav_web/members/templatetags/tex_extras.py @@ -1,5 +1,6 @@ from django import template from django.utils.safestring import mark_safe +from datetime import timedelta register = template.Library() @@ -14,6 +15,12 @@ def checked_if_true(name, value): def esc_all(val): return mark_safe(str(val).replace('_', '\\_').replace('&', '\\&').replace('%', '\\%')) +@register.filter +def index(sequence, position): + try: + return sequence[position] + except (IndexError, TypeError): + return '' @register.filter def datetime_short(date): @@ -24,7 +31,22 @@ def datetime_short(date): def date_short(date): return date.strftime('%d.%m.%y') +@register.filter +def date_vs(date): + return date.strftime('%d.%m.') @register.filter def time_short(date): return date.strftime('%H:%M') + +@register.filter +def add(date, days): + if days: + return date + timedelta(days=days) + return date + +@register.filter +def plus(num1, num2): + if num2: + return num1 + num2 + return num1 diff --git a/jdav_web/templates/admin/members/group/change_list.html b/jdav_web/templates/admin/members/group/change_list.html index e688e94..081adcd 100644 --- a/jdav_web/templates/admin/members/group/change_list.html +++ b/jdav_web/templates/admin/members/group/change_list.html @@ -7,6 +7,7 @@
{% csrf_token %} +
{{block.super}} diff --git a/jdav_web/utils.py b/jdav_web/utils.py index 129e495..021dd40 100644 --- a/jdav_web/utils.py +++ b/jdav_web/utils.py @@ -1,4 +1,4 @@ -from datetime import datetime +from datetime import datetime, timedelta from django.db import models from django.utils import timezone from django.core.exceptions import ValidationError @@ -88,3 +88,11 @@ def coming_midnight(): return timezone.datetime(year=base.year, month=base.month, day=base.day, hour=0, minute=0, second=0, microsecond=0, tzinfo=base.tzinfo) + + +def mondays_until_nth(n): + """ Returns a list of dates for the next n Mondays, starting from the next Monday. + This functions aids in the generation of weekly schedules or reports.""" + today = datetime.today() + next_monday = today + timedelta(days=(7 - today.weekday()) % 7 or 7) + return [(next_monday + timedelta(weeks=i)).date() for i in range(n + 1)]