Skip to content

Commit

Permalink
Merge pull request #3 from openprocurement/new_sync
Browse files Browse the repository at this point in the history
New chronograph mechanism of auction planning and replanning
  • Loading branch information
kroman0 committed Feb 12, 2016
2 parents 9912da9 + 679e4bc commit 4675927
Show file tree
Hide file tree
Showing 10 changed files with 139 additions and 40 deletions.
2 changes: 2 additions & 0 deletions .travis.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ python:
- "2.7"
services:
- couchdb
env:
- TZ=Europe/Kiev
cache:
directories:
- eggs
Expand Down
Empty file modified bootstrap.py
100644 → 100755
Empty file.
Empty file modified docs.py
100644 → 100755
Empty file.
6 changes: 0 additions & 6 deletions openprocurement/tender/openua/interfaces.py

This file was deleted.

75 changes: 68 additions & 7 deletions openprocurement/tender/openua/models.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# -*- coding: utf-8 -*-
from datetime import timedelta
from datetime import timedelta, time, datetime
from zope.interface import implementer
from schematics.types import StringType, BooleanType
from schematics.types.compound import ModelType
Expand All @@ -19,9 +19,9 @@
auction_role, chronograph_role, chronograph_view_role, view_bid_role,
Administrator_bid_role, Administrator_role, schematics_default_role,
TZ, get_now, schematics_embedded_role, validate_lots_uniq,
embedded_lot_role, default_lot_role,
embedded_lot_role, default_lot_role, calc_auction_end_time, get_tender,
)
from openprocurement.tender.openua.interfaces import ITenderUA
from openprocurement.api.models import ITender
from openprocurement.tender.openua.utils import (
calculate_business_date, BLOCK_COMPLAINT_STATUS,
)
Expand All @@ -45,7 +45,7 @@ def validator(klass, data, value):
return
tender = data['__parent__']
request = tender.__parent__.request
if request.method == "PATCH" and ITenderUA.providedBy(request.context) and request.authenticated_role == "tender_owner":
if request.method == "PATCH" and isinstance(tender, Tender) and request.authenticated_role == "tender_owner":
# disable bids validation on tender PATCH requests as tender bids will be invalidated
return
return validation_func(klass, data, value)
Expand Down Expand Up @@ -101,6 +101,51 @@ def export_loop(self, list_instance, field_converter,
return data


class TenderAuctionPeriod(Period):
"""The auction period."""

@serializable(serialize_when_none=False)
def shouldStartAfter(self):
if self.endDate:
return
tender = self.__parent__
if tender.lots or tender.status not in ['active.tendering', 'active.auction']:
return
if self.startDate and get_now() > calc_auction_end_time(tender.numberOfBids, self.startDate):
return calc_auction_end_time(tender.numberOfBids, self.startDate).isoformat()
else:
decision_dates = [
datetime.combine(complaint.dateDecision.date() + timedelta(days=3), time(0, tzinfo=complaint.dateDecision.tzinfo))
for complaint in tender.complaints
if complaint.dateDecision
]
decision_dates.append(tender.tenderPeriod.endDate)
return max(decision_dates).isoformat()


class LotAuctionPeriod(Period):
"""The auction period."""

@serializable(serialize_when_none=False)
def shouldStartAfter(self):
if self.endDate:
return
tender = get_tender(self)
lot = self.__parent__
if tender.status not in ['active.tendering', 'active.auction'] or lot.status != 'active':
return
if self.startDate and get_now() > calc_auction_end_time(lot.numberOfBids, self.startDate):
return calc_auction_end_time(lot.numberOfBids, self.startDate).isoformat()
else:
decision_dates = [
datetime.combine(complaint.dateDecision.date() + timedelta(days=3), time(0, tzinfo=complaint.dateDecision.tzinfo))
for complaint in tender.complaints
if complaint.dateDecision
]
decision_dates.append(tender.tenderPeriod.endDate)
return max(decision_dates).isoformat()


class Bid(BaseBid):

class Options:
Expand Down Expand Up @@ -225,6 +270,8 @@ class Options:
'chronograph_view': whitelist('id', 'auctionPeriod', 'numberOfBids', 'status'),
}

auctionPeriod = ModelType(LotAuctionPeriod, default={})

@serializable
def numberOfBids(self):
"""A property that is serialized by schematics exports."""
Expand All @@ -236,7 +283,7 @@ def numberOfBids(self):
return len(bids)


@implementer(ITenderUA)
@implementer(ITender)
class Tender(BaseTender):
"""Data regarding tender process - publicly inviting prospective contractors to submit bids for evaluation and selecting a winner or winners."""

Expand Down Expand Up @@ -274,6 +321,7 @@ class Options:

enquiryPeriod = ModelType(Period, required=False)
tenderPeriod = ModelType(PeriodStartEndRequired, required=True)
auctionPeriod = ModelType(TenderAuctionPeriod, default={})
bids = SifterListType(ModelType(Bid), default=list(), filter_by='status', filter_in_values=['invalid', 'deleted']) # A list of all the companies who entered submissions for the tender.
awards = ListType(ModelType(Award), default=list())
complaints = ListType(ModelType(Complaint), default=list())
Expand Down Expand Up @@ -301,12 +349,25 @@ def numberOfBids(self):
"""A property that is serialized by schematics exports."""
return len([bid for bid in self.bids if bid.status == "active"])

@serializable
@serializable(serialize_when_none=False)
def next_check(self):
now = get_now()
checks = []
if self.status == 'active.tendering' and self.tenderPeriod.endDate and not any([i.status in BLOCK_COMPLAINT_STATUS for i in self.complaints]):
checks.append(self.tenderPeriod.endDate.astimezone(TZ))
elif not self.lots and self.status == 'active.auction' and self.auctionPeriod and self.auctionPeriod.startDate and not self.auctionPeriod.endDate:
if now < self.auctionPeriod.startDate:
checks.append(self.auctionPeriod.startDate.astimezone(TZ))
elif now < calc_auction_end_time(self.numberOfBids, self.auctionPeriod.startDate).astimezone(TZ):
checks.append(calc_auction_end_time(self.numberOfBids, self.auctionPeriod.startDate).astimezone(TZ))
elif self.lots and self.status == 'active.auction':
for lot in self.lots:
if lot.status != 'active' or not lot.auctionPeriod or not lot.auctionPeriod.startDate or lot.auctionPeriod.endDate:
continue
if now < lot.auctionPeriod.startDate:
checks.append(lot.auctionPeriod.startDate.astimezone(TZ))
elif now < calc_auction_end_time(lot.numberOfBids, lot.auctionPeriod.startDate).astimezone(TZ):
checks.append(calc_auction_end_time(lot.numberOfBids, lot.auctionPeriod.startDate).astimezone(TZ))
elif not self.lots and self.status == 'active.awarded':
standStillEnds = [
a.complaintPeriod.endDate.astimezone(TZ)
Expand Down Expand Up @@ -335,7 +396,7 @@ def next_check(self):
lots_ends.append(standStillEnd)
if lots_ends:
checks.append(min(lots_ends))
return sorted(checks)[0].isoformat() if checks else None
return min(checks).isoformat() if checks else None

def invalidate_bids_data(self):
for bid in self.bids:
Expand Down
52 changes: 49 additions & 3 deletions openprocurement/tender/openua/tests/chronograph.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,10 @@ def test_set_auction_period(self):

response = self.app.patch_json('/tenders/{}'.format(self.tender_id), {'data': {"auctionPeriod": {"startDate": None}}})
self.assertEqual(response.status, '200 OK')
self.assertNotIn('auctionPeriod', response.json['data'])

self.assertIn('auctionPeriod', response.json['data'])
self.assertNotIn('startDate', response.json['data']['auctionPeriod'])


class TenderSwitch1BidResourceTest(BaseTenderUAContentWebTest):
initial_bids = test_bids[:1]

Expand Down Expand Up @@ -117,6 +118,28 @@ def test_switch_to_unsuccessful(self):
self.assertEqual(response.status, '200 OK')
self.assertEqual(response.json['data']["status"], "unsuccessful")

def test_set_auction_period(self):
self.app.authorization = ('Basic', ('chronograph', ''))
response = self.app.patch_json('/tenders/{}'.format(self.tender_id), {'data': {'id': self.tender_id}})
self.assertEqual(response.status, '200 OK')
self.assertEqual(response.content_type, 'application/json')
self.assertEqual(response.json['data']["status"], 'active.tendering')
item = response.json['data']
self.assertIn('auctionPeriod', item)
self.assertIn('shouldStartAfter', item['auctionPeriod'])
self.assertEqual(item['auctionPeriod']['shouldStartAfter'], response.json['data']['tenderPeriod']['endDate'])
self.assertEqual(response.json['data']['next_check'], response.json['data']['tenderPeriod']['endDate'])

response = self.app.patch_json('/tenders/{}'.format(self.tender_id), {'data': {"auctionPeriod": {"startDate": "9999-01-01T00:00:00+00:00"}}})
item = response.json['data']
self.assertEqual(response.status, '200 OK')
self.assertEqual(item['auctionPeriod']['startDate'], '9999-01-01T00:00:00+00:00')

response = self.app.patch_json('/tenders/{}'.format(self.tender_id), {'data': {"auctionPeriod": {"startDate": None}}})
item = response.json['data']
self.assertEqual(response.status, '200 OK')
self.assertNotIn('startDate', item['auctionPeriod'])


class TenderLotSwitch0BidResourceTest(BaseTenderUAContentWebTest):
initial_lots = test_lots
Expand All @@ -141,7 +164,8 @@ def test_set_auction_period(self):

response = self.app.patch_json('/tenders/{}'.format(self.tender_id), {'data': {"lots": [{"auctionPeriod": {"startDate": None}}]}})
self.assertEqual(response.status, '200 OK')
self.assertNotIn('auctionPeriod', response.json['data']["lots"][0])
self.assertIn('auctionPeriod', response.json['data']["lots"][0])
self.assertNotIn('startDate', response.json['data']["lots"][0]['auctionPeriod'])


class TenderLotSwitch1BidResourceTest(BaseTenderUAContentWebTest):
Expand Down Expand Up @@ -200,6 +224,28 @@ def test_switch_to_unsuccessful(self):
self.assertEqual(response.status, '200 OK')
self.assertEqual(response.json['data']["status"], "unsuccessful")

def test_set_auction_period(self):
self.app.authorization = ('Basic', ('chronograph', ''))
response = self.app.patch_json('/tenders/{}'.format(self.tender_id), {'data': {'id': self.tender_id}})
self.assertEqual(response.status, '200 OK')
self.assertEqual(response.content_type, 'application/json')
self.assertEqual(response.json['data']["status"], 'active.tendering')
item = response.json['data']["lots"][0]
self.assertIn('auctionPeriod', item)
self.assertIn('shouldStartAfter', item['auctionPeriod'])
self.assertEqual(item['auctionPeriod']['shouldStartAfter'], response.json['data']['tenderPeriod']['endDate'])
self.assertEqual(response.json['data']['next_check'], response.json['data']['tenderPeriod']['endDate'])

response = self.app.patch_json('/tenders/{}'.format(self.tender_id), {'data': {"lots": [{"auctionPeriod": {"startDate": "9999-01-01T00:00:00+00:00"}} for i in self.initial_lots]}})
item = response.json['data']["lots"][0]
self.assertEqual(response.status, '200 OK')
self.assertEqual(item['auctionPeriod']['startDate'], '9999-01-01T00:00:00+00:00')

response = self.app.patch_json('/tenders/{}'.format(self.tender_id), {'data': {"lots": [{"auctionPeriod": {"startDate": None}} for i in self.initial_lots]}})
item = response.json['data']["lots"][0]
self.assertEqual(response.status, '200 OK')
self.assertNotIn('startDate', item['auctionPeriod'])


class Tender2LotSwitch0BidResourceTest(TenderLotSwitch0BidResourceTest):
initial_lots = 2 * test_lots
Expand Down
6 changes: 4 additions & 2 deletions openprocurement/tender/openua/tests/lot.py
Original file line number Diff line number Diff line change
Expand Up @@ -353,13 +353,14 @@ def test_get_tender_lot(self):
response = self.app.get('/tenders/{}/lots/{}'.format(self.tender_id, lot['id']))
self.assertEqual(response.status, '200 OK')
self.assertEqual(response.content_type, 'application/json')
self.assertEqual(set(response.json['data']), set([u'id', u'title', u'description', u'minimalStep', u'value', u'status']))
self.assertEqual(set(response.json['data']), set([u'status', u'description', u'title', u'minimalStep', u'auctionPeriod', u'value', u'id']))

self.set_status('active.qualification')

response = self.app.get('/tenders/{}/lots/{}'.format(self.tender_id, lot['id']))
self.assertEqual(response.status, '200 OK')
self.assertEqual(response.content_type, 'application/json')
lot.pop('auctionPeriod')
self.assertEqual(response.json['data'], lot)

response = self.app.get('/tenders/{}/lots/some_id'.format(self.tender_id), status=404)
Expand Down Expand Up @@ -389,13 +390,14 @@ def test_get_tender_lots(self):
response = self.app.get('/tenders/{}/lots'.format(self.tender_id))
self.assertEqual(response.status, '200 OK')
self.assertEqual(response.content_type, 'application/json')
self.assertEqual(set(response.json['data'][0]), set([u'id', u'title', u'description', u'minimalStep', u'value', u'status']))
self.assertEqual(set(response.json['data'][0]), set([u'status', u'description', u'title', u'minimalStep', u'auctionPeriod', u'value', u'id']))

self.set_status('active.qualification')

response = self.app.get('/tenders/{}/lots'.format(self.tender_id))
self.assertEqual(response.status, '200 OK')
self.assertEqual(response.content_type, 'application/json')
lot.pop('auctionPeriod')
self.assertEqual(response.json['data'][0], lot)

response = self.app.get('/tenders/some_id/lots', status=404)
Expand Down
17 changes: 12 additions & 5 deletions openprocurement/tender/openua/tests/tender.py
Original file line number Diff line number Diff line change
Expand Up @@ -504,9 +504,13 @@ def test_create_tender_generated(self):
self.assertEqual(response.status, '201 Created')
self.assertEqual(response.content_type, 'application/json')
tender = response.json['data']
self.assertEqual(set(tender), set([u'procurementMethodType', u'id', u'dateModified', u'tenderID', u'status', u'enquiryPeriod',
u'tenderPeriod', u'complaintPeriod', u'minimalStep', u'items', u'value', u'procuringEntity', u'next_check',
u'procurementMethod', u'awardCriteria', u'submissionMethod', u'title', u'owner']))
self.assertEqual(set(tender), set([
u'procurementMethodType', u'id', u'dateModified', u'tenderID',
u'status', u'enquiryPeriod', u'tenderPeriod', u'complaintPeriod',
u'minimalStep', u'items', u'value', u'procuringEntity',
u'next_check', u'procurementMethod', u'awardCriteria',
u'submissionMethod', u'auctionPeriod', u'title', u'owner',
]))
self.assertNotEqual(data['id'], tender['id'])
self.assertNotEqual(data['doc_id'], tender['id'])
self.assertNotEqual(data['tenderID'], tender['tenderID'])
Expand All @@ -520,8 +524,11 @@ def test_create_tender(self):
self.assertEqual(response.status, '201 Created')
self.assertEqual(response.content_type, 'application/json')
tender = response.json['data']
self.assertEqual(set(tender) - set(test_tender_ua_data), set(
[u'id', u'dateModified', u'enquiryPeriod', u'complaintPeriod', u'tenderID', u'status', u'procurementMethod', u'awardCriteria', u'submissionMethod', u'next_check', u'owner']))
self.assertEqual(set(tender) - set(test_tender_ua_data), set([
u'id', u'dateModified', u'enquiryPeriod', u'auctionPeriod',
u'complaintPeriod', u'tenderID', u'status', u'procurementMethod',
u'awardCriteria', u'submissionMethod', u'next_check', u'owner',
]))
self.assertIn(tender['id'], response.headers['Location'])

response = self.app.get('/tenders/{}'.format(tender['id']))
Expand Down
7 changes: 3 additions & 4 deletions openprocurement/tender/openua/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,14 @@ def calculate_business_date(date_obj, timedelta_obj, context=None):
def check_bids(request):
tender = request.validated['tender']
if tender.lots:
[setattr(i.auctionPeriod, 'startDate', None) for i in tender.lots if i.numberOfBids < 2 and i.auctionPeriod and i.auctionPeriod.startDate]
[setattr(i, 'status', 'unsuccessful') for i in tender.lots if i.numberOfBids < 2]
if not set([i.status for i in tender.lots]).difference(set(['unsuccessful', 'cancelled'])):
tender.status = 'unsuccessful'
else:
if tender.numberOfBids < 2:
if tender.auctionPeriod and tender.auctionPeriod.startDate:
tender.auctionPeriod.startDate = None
tender.status = 'unsuccessful'


Expand Down Expand Up @@ -92,7 +95,6 @@ def check_status(request):
elif tender.lots and tender.status in ['active.qualification', 'active.awarded']:
if any([i['status'] in PENDING_COMPLAINT_STATUS and i.relatedLot is None for i in tender.complaints]):
return
lots_ends = []
for lot in tender.lots:
if lot['status'] != 'active':
continue
Expand Down Expand Up @@ -123,9 +125,6 @@ def check_status(request):
LOGGER.info('Switched lot {} of tender {} to {}'.format(lot['id'], tender.id, 'unsuccessful'),
extra=context_unpack(request, {'MESSAGE_ID': 'switched_lot_unsuccessful'}, {'LOT_ID': lot['id']}))
check_tender_status(request)
return
elif standStillEnd > now:
lots_ends.append(standStillEnd)


def add_next_award(request):
Expand Down
14 changes: 1 addition & 13 deletions openprocurement/tender/openua/views/complaint.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
# -*- coding: utf-8 -*-
from datetime import timedelta, time, datetime
from logging import getLogger
from openprocurement.api.models import get_now
from openprocurement.api.views.complaint import TenderComplaintResource
Expand Down Expand Up @@ -131,23 +130,12 @@ def patch(self):
elif self.request.authenticated_role == 'reviewers' and self.context.status == 'pending' and data.get('status', self.context.status) == 'invalid':
apply_patch(self.request, save=False, src=self.context.serialize())
self.context.dateDecision = get_now()
tenderPeriodendDate = datetime.combine(self.context.dateDecision.date(), time(0, tzinfo=self.context.dateDecision.tzinfo)) + timedelta(days=3)
if tender.tenderPeriod.endDate < tenderPeriodendDate:
tender.tenderPeriod.endDate = tenderPeriodendDate
tender.auctionPeriod = None
elif self.request.authenticated_role == 'reviewers' and self.context.status == 'pending' and data.get('status', self.context.status) == 'accepted':
apply_patch(self.request, save=False, src=self.context.serialize())
self.context.dateAccepted = get_now()
elif self.request.authenticated_role == 'reviewers' and self.context.status == 'accepted' and data.get('status', self.context.status) == self.context.status:
apply_patch(self.request, save=False, src=self.context.serialize())
elif self.request.authenticated_role == 'reviewers' and self.context.status == 'accepted' and data.get('status', self.context.status) == 'declined':
apply_patch(self.request, save=False, src=self.context.serialize())
self.context.dateDecision = get_now()
tenderPeriodendDate = datetime.combine(self.context.dateDecision.date(), time(0, tzinfo=self.context.dateDecision.tzinfo)) + timedelta(days=3)
if tender.tenderPeriod.endDate < tenderPeriodendDate:
tender.tenderPeriod.endDate = tenderPeriodendDate
tender.auctionPeriod = None
elif self.request.authenticated_role == 'reviewers' and self.context.status == 'accepted' and data.get('status', self.context.status) == 'satisfied':
elif self.request.authenticated_role == 'reviewers' and self.context.status == 'accepted' and data.get('status', self.context.status) in ['declined', 'satisfied']:
apply_patch(self.request, save=False, src=self.context.serialize())
self.context.dateDecision = get_now()
else:
Expand Down

0 comments on commit 4675927

Please sign in to comment.