Skip to content
This repository has been archived by the owner on Apr 5, 2024. It is now read-only.

Commit

Permalink
Merge branch 'develop'
Browse files Browse the repository at this point in the history
  • Loading branch information
jmp committed Dec 8, 2021
2 parents b563eee + 833b274 commit e6d164c
Show file tree
Hide file tree
Showing 28 changed files with 740 additions and 284 deletions.
3 changes: 2 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ RUN pip install --no-cache-dir -r deploy/requirements.txt

COPY . .

RUN npm install -g npm && ./build-resources && apt-get remove -y npm && apt autoremove -y
# Upgrading NPM will cause an error "Unexpected token =" during the build
# RUN npm install -g npm && ./build-resources && apt-get remove -y npm && apt autoremove -y

RUN mkdir -p www/media

Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
[![Build Status](https://api.travis-ci.org/City-of-Helsinki/respa.svg?branch=master)](https://travis-ci.org/City-of-Helsinki/respa)
[![codecov](https://codecov.io/gh/City-of-Helsinki/respa/branch/master/graph/badge.svg)](https://codecov.io/gh/City-of-Helsinki/respa)
[![Requirements Status](https://requires.io/github/City-of-Helsinki/respa/requirements.svg?branch=master)](https://requires.io/github/City-of-Helsinki/respa/requirements/?branch=master)
[![Build Status](https://dev.azure.com/City-of-Helsinki/respa/_apis/build/status/City-of-Helsinki.respa?repoName=City-of-Helsinki%2Frespa&branchName=develop)](https://dev.azure.com/City-of-Helsinki/respa/_build/latest?definitionId=106&repoName=City-of-Helsinki%2Frespa&branchName=develop)

Respa – Resource reservation and management service
===================
Expand Down
2 changes: 1 addition & 1 deletion azure-pipelines-develop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
trigger:
branches:
include:
- frozendevelop
- develop
paths:
exclude:
- README.md
Expand Down
31 changes: 28 additions & 3 deletions docs/payments.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ There are a couple of required configuration keys that need to be set in order t
- `RESPA_PAYMENTS_ENABLED`: Whether payments are enabled or not. Boolean `True`/`False`. The default value is `False`.
- `RESPA_PAYMENTS_PROVIDER_CLASS`: Dotted path to the active provider class e.g. `payments.providers.BamboraPayformProvider` as a string. No default value.
- `RESPA_PAYMENTS_PAYMENT_WAITING_TIME`: In minutes, how old the potential unpaid orders/reservations have to be in order for Respa cleanup to set them expired. The default value is `15`.
- `RESPA_PAYMENTS_PAYMENT_REQUESTED_WAITING_TIME`: In hours, how old requested unpaid orders/reservations have to be after staff confirmation in order for Respa cleanup to set them expired. The default value is `24`.


`./manage.py expire_too_old_unpaid_orders` runs the order/reservation cleanup for current orders. You'll probably want to run it periodically at least in production. [Cron](https://en.wikipedia.org/wiki/Cron) is one candidate for doing that.

Expand All @@ -26,20 +28,22 @@ In addition to the general configuration keys mentioned in the previous section,
- `RESPA_PAYMENTS_BAMBORA_API_KEY`: Identifies which merchant store account to use with Bambora. Value can be found in the merchant portal. Provided as a string. No default value.
- `RESPA_PAYMENTS_BAMBORA_API_SECRET`: Used to calculate hashes out of the data being sent and received, to verify it is not being tampered with. Also found in the merchant portal and provided as a string. No default value.
- `RESPA_PAYMENTS_BAMBORA_PAYMENT_METHODS`: An array of payment methods to show to the user to select from e.g.`['nordea', 'creditcards']`. Full list of supported values can be found in [the currencies section of](https://payform.bambora.com/docs/web_payments/?page=full-api-reference#currencies) Bambora's API documentation page.
- `RESPA_PAYMENTS_BAMBORA_TOKEN_VALID_DAYS`: In days, how long payment token used for payment link is valid and usable for customer to make payment.


## Basics

Model `Product` represents everything that can be ordered and paid alongside a reservation. Products are linked to one or multiple resources.

There are currently two types of products:

- `rent`: At least one product of type `rent` must be ordered when such is available on the resource.
- `rent`: At least one product of type `rent` must be ordered when such is available on the resource.

- `extra`: Ordering of products of type `extra` is not mandatory, so when there are only `extra` products available, one can create a reservation without an order. However, when an order is created, even with just extra product(s), it must be paid to get the reservation confirmed.

Everytime a product is saved, a new copy of it is created in the db, so product modifying does not affect already existing orders.

All prices are in euros. A product's price is stored in `price` field. However, there are different ways the value should be interpreted depending on `price_type` field's value:
All prices are in euros. A product's price is stored in `price` field. However, there are different ways the value should be interpreted depending on `price_type` field's value:

- `fixed`: The price stays always the same regardless of the reservation, so if `price` is `10.00` the final price is 10.00 EUR.

Expand All @@ -49,7 +53,11 @@ Model `Order` represents orders of products. One and only one order is linked to

An order can be in state `waiting`, `confirmed`, `rejected`, `expired` or `cancelled`. A new order will start from state `waiting`, and from there it will change to one of the other states. Only valid other state change is from `confirmed` to `cancelled`.

An order is created by providing its data in `order` field when creating a reservation via the API. The UI must also provide a return URL to which the user will be redirected after the payment process has been completed. In the creation response the UI gets back a payment URL, to which it must redirect the user to start the actual payment process.
An order is created by providing its data in `order` field when creating a reservation via the API. The UI must also provide a return URL to which the user will be redirected after the payment process has been completed. In the creation response the UI gets back a payment URL, to which it must redirect the user to start the actual payment process.

## Requested reservations

Payment integration has been implemented to support reservations in resources that requires staff confirmation. In that case payment link is sent to reserver in email after staff has changed reservations state to `waiting_for_payment`. `NotificationTemplate` with `NotificationType` `RESERVATION_WAITING_FOR_PAYMENT` must be defined and the template body must include `{{payment_url}}` tag for payment link to be included in the email. Default wait time for payment before order gets expired is 24 hours and it can be changed with setting `RESPA_PAYMENTS_PAYMENT_REQUESTED_WAITING_TIME`.

## Administration

Expand Down Expand Up @@ -235,6 +243,23 @@ Example full return url: `https://varaamo.hel.fi/payment-return-url/?payment_sta

Modifying an order is not possible, and after a reservation's creation the `order` field is read-only.

### Custom price

Admins and some staff members with permission `can_set_custom_price_for_reservations` can set custom price for individual reservation. The custom price is set with PUT-request to Reservations details endpoint.

Example of custom_price field in Reservation PUT request

```json
...

"custom_price": {
"price": "10.00",
"price_type": "half"
}

...
```

### Order data in reservation API endpoint

Reservation data in the API includes `order` field when the current user has permission to view it (either own reservation or via the explicit view order permission).
Expand Down
4 changes: 4 additions & 0 deletions docs/permissions.rst
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,7 @@ can_create_reservations_for_other_users X X X
can_create_overlapping_reservations X X X X
can_ignore_max_reservations_per_user X X X X
can_ignore_max_period X X X X
can_set_custom_price_for_reservations X X X X
====================================== ====== ======= ====== ====== ======


Expand Down Expand Up @@ -208,6 +209,9 @@ can_ignore_max_reservations_per_user
can_ignore_max_period
Can ignore resources max period rule

can_set_custom_price_for_reservations
Can set custom price for individual reservations


Respa Admin Permissions
~~~~~~~~~~~~~~~~~~~~~~~
Expand Down
18 changes: 18 additions & 0 deletions notifications/migrations/0006_add_new_notification_type.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Generated by Django 2.2.13 on 2020-09-28 13:00

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('notifications', '0005_add_access_code_created_notification'),
]

operations = [
migrations.AlterField(
model_name='notificationtemplate',
name='type',
field=models.CharField(choices=[('reservation_requested', 'Reservation requested'), ('reservation_requested_official', 'Reservation requested official'), ('reservation_cancelled', 'Reservation cancelled'), ('reservation_confirmed', 'Reservation confirmed'), ('reservation_created', 'Reservation created'), ('reservation_denied', 'Reservation denied'), ('reservation_created_with_access_code', 'Reservation created with access code'), ('reservation_access_code_created', 'Access code was created for a reservation'), ('reservation_waiting_for_payment', 'Reservation waiting for payment'), ('catering_order_created', 'Catering order created'), ('catering_order_modified', 'Catering order modified'), ('catering_order_deleted', 'Catering order deleted'), ('reservation_comment_created', 'Reservation comment created'), ('catering_order_comment_created', 'Catering order comment created')], db_index=True, max_length=100, unique=True, verbose_name='Type'),
),
]
2 changes: 2 additions & 0 deletions notifications/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ class NotificationType:
# we don't confuse the user with "new reservation created"-style
# messaging.
RESERVATION_ACCESS_CODE_CREATED = 'reservation_access_code_created'
RESERVATION_WAITING_FOR_PAYMENT = 'reservation_waiting_for_payment'
CATERING_ORDER_CREATED = 'catering_order_created'
CATERING_ORDER_MODIFIED = 'catering_order_modified'
CATERING_ORDER_DELETED = 'catering_order_deleted'
Expand All @@ -54,6 +55,7 @@ class NotificationTemplate(TranslatableModel):
(NotificationType.RESERVATION_DENIED, _('Reservation denied')),
(NotificationType.RESERVATION_CREATED_WITH_ACCESS_CODE, _('Reservation created with access code')),
(NotificationType.RESERVATION_ACCESS_CODE_CREATED, _('Access code was created for a reservation')),
(NotificationType.RESERVATION_WAITING_FOR_PAYMENT, _('Reservation waiting for payment')),

(NotificationType.CATERING_ORDER_CREATED, _('Catering order created')),
(NotificationType.CATERING_ORDER_MODIFIED, _('Catering order modified')),
Expand Down
37 changes: 34 additions & 3 deletions payments/api/reservation.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
)
from resources.api.reservation import ReservationSerializer

from ..models import OrderLine, Product
from ..models import OrderLine, Product, ReservationCustomPrice
from ..providers import get_payment_provider
from .base import OrderSerializerBase

Expand All @@ -18,7 +18,7 @@ class ReservationEndpointOrderSerializer(OrderSerializerBase):
payment_url = serializers.SerializerMethodField()

class Meta(OrderSerializerBase.Meta):
fields = OrderSerializerBase.Meta.fields + ('id', 'return_url', 'payment_url')
fields = OrderSerializerBase.Meta.fields + ('id', 'return_url', 'payment_url', 'is_requested_order')

def create(self, validated_data):
order_lines_data = validated_data.pop('order_lines', [])
Expand All @@ -32,6 +32,8 @@ def create(self, validated_data):
ui_return_url=return_url)
try:
self.context['payment_url'] = payments.initiate_payment(order)
order.payment_url = self.context['payment_url']
order.save()
except DuplicateOrderError as doe:
raise exceptions.APIException(detail=str(doe),
code=status.HTTP_409_CONFLICT)
Expand Down Expand Up @@ -81,9 +83,14 @@ def to_representation(self, instance):

return data

class ReservationEndpointCustomPriceSerializer(serializers.ModelSerializer):
class Meta:
model = ReservationCustomPrice
fields = ('price', 'price_type')

class PaymentsReservationSerializer(ReservationSerializer):
order = serializers.SlugRelatedField('order_number', read_only=True)
custom_price = ReservationEndpointCustomPriceSerializer(required=False)

def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
Expand All @@ -104,7 +111,7 @@ def __init__(self, *args, **kwargs):
self.fields['order'] = ReservationEndpointOrderSerializer(read_only=True)

class Meta(ReservationSerializer.Meta):
fields = ReservationSerializer.Meta.fields + ['order']
fields = ReservationSerializer.Meta.fields + ['order', 'custom_price']

def to_representation(self, instance):
data = super().to_representation(instance)
Expand All @@ -118,18 +125,42 @@ def to_representation(self, instance):
def create(self, validated_data):
order_data = validated_data.pop('order', None)
reservation = super().create(validated_data)
prefetched_user = self.context.get('prefetched_user', None)
user = prefetched_user or self.context['request'].user

if order_data:
if not reservation.can_add_product_order(self.context['request'].user):
raise PermissionDenied()

order_data['reservation'] = reservation
resource = reservation.resource
if resource.need_manual_confirmation and not resource.can_bypass_manual_confirmation(user):
order_data['is_requested_order'] = True

ReservationEndpointOrderSerializer(context=self.context).create(validated_data=order_data)

return reservation

def update(self, instance, validated_data):
custom_price_data = validated_data.pop('custom_price', None)
reservation = super().update(instance, validated_data)
prefetched_user = self.context.get('prefetched_user', None)
user = prefetched_user or self.context['request'].user

if custom_price_data:
if not reservation.can_set_custom_price(user):
raise PermissionDenied()
if hasattr(reservation, 'custom_price'):
reservation.custom_price.delete()
custom_price_data['reservation'] = reservation
ReservationEndpointCustomPriceSerializer(context=self.context).create(validated_data=custom_price_data)

return reservation

def validate(self, data):
order_data = data.pop('order', None)
custom_price_data = data.pop('custom_price', None)
data = super().validate(data)
data['custom_price'] = custom_price_data
data['order'] = order_data
return data
18 changes: 18 additions & 0 deletions payments/migrations/0002_order_payment_url.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Generated by Django 2.2.13 on 2020-09-28 09:15

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('payments', '0001_initial'),
]

operations = [
migrations.AddField(
model_name='order',
name='payment_url',
field=models.CharField(blank=True, default='', max_length=200, verbose_name='payment url'),
),
]
23 changes: 23 additions & 0 deletions payments/migrations/0003_order_requested_payment_fields.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Generated by Django 2.2.13 on 2020-09-28 14:54

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('payments', '0002_order_payment_url'),
]

operations = [
migrations.AddField(
model_name='order',
name='confirmed_by_staff_at',
field=models.DateTimeField(blank=True, null=True, verbose_name='confirmed by staff at'),
),
migrations.AddField(
model_name='order',
name='is_requested_order',
field=models.BooleanField(default=False, verbose_name='is requested order'),
),
]
30 changes: 30 additions & 0 deletions payments/migrations/0004_reservationcustomprice.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# Generated by Django 2.2.13 on 2020-10-05 10:20

from decimal import Decimal
import django.core.validators
from django.db import migrations, models
import django.db.models.deletion


class Migration(migrations.Migration):

dependencies = [
('resources', '0095_resource_attachments_allow_empty'),
('payments', '0003_order_requested_payment_fields'),
]

operations = [
migrations.CreateModel(
name='ReservationCustomPrice',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('price', models.DecimalField(decimal_places=2, max_digits=10, validators=[django.core.validators.MinValueValidator(Decimal('0.01'))], verbose_name='price including VAT')),
('price_type', models.CharField(choices=[('half', 'half'), ('free', 'free'), ('custom', 'custom')], max_length=32, verbose_name='price type')),
('reservation', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='custom_price', to='resources.Reservation', verbose_name='custom price')),
],
options={
'verbose_name': 'custom price',
'verbose_name_plural': 'custom prices',
},
),
]
Loading

0 comments on commit e6d164c

Please sign in to comment.