Merge lp:~marco-gallotta/ibid/uva into lp:~ibid-core/ibid/old-trunk-1.6

Proposed by marcog
Status: Work in progress
Proposed branch: lp:~marco-gallotta/ibid/uva
Merge into: lp:~ibid-core/ibid/old-trunk-1.6
Diff against target: 853 lines (+666/-36)
3 files modified
ibid/db/__init__.py (+2/-1)
ibid/event.py (+1/-1)
ibid/plugins/codecontest.py (+663/-34)
To merge this branch: bzr merge lp:~marco-gallotta/ibid/uva
Reviewer Review Type Date Requested Status
Stefano Rivera Needs Fixing
Max Rabkin Needs Fixing
Jonathan Hitchcock Abstain
Michael Gorven Approve
Review via email: mp+19277@code.launchpad.net

This proposal supersedes a proposal from 2010-01-31.

To post a comment you must log in.
Revision history for this message
marcog (marco-gallotta) wrote : Posted in a previous version of this proposal

Add UVa support to codecontest plugin. UVa run ACM ICPC-style programming contests. I hope to support ACM ICPC-style judges in the future, so I've tried to separate out some of the non-UVa-specific bits for now.

Revision history for this message
Max Rabkin (max-rabkin) wrote : Posted in a previous version of this proposal

I'm not sure how I feel about setting the current contest via config. Config is for, well, configuration.

I'd prefer "monitor uva <id>" -- and if there's only one running contest (this usually seems to be the case on UVa) then one shouldn't need to specify.

Revision history for this message
Keegan Carruthers-Smith (keegan-csmith) wrote : Posted in a previous version of this proposal

> I'm not sure how I feel about setting the current contest via config. Config
> is for, well, configuration.
>
> I'd prefer "monitor uva <id>" -- and if there's only one running contest (this
> usually seems to be the case on UVa) then one shouldn't need to specify.

I agree with Max. Config feels dirty. I would enforce only one contest at a time.

Revision history for this message
marcog (marco-gallotta) wrote : Posted in a previous version of this proposal

> I'm not sure how I feel about setting the current contest via config. Config
> is for, well, configuration.
>
> I'd prefer "monitor uva <id>" -- and if there's only one running contest (this
> usually seems to be the case on UVa) then one shouldn't need to specify.

I originally had it like you suggest, but there were a few issues with that -- maybe we can get past them though. The "uva countdown" feature works before the contest starts -- perhaps it can be made to countdown to the next contest and take an optional <id> arg. The "uva scoreboard" feature only could do the same -- except, it uses the current/previous contest if an <id> is not provided.

Usage would then become

14:37 < mibid> marcog: You can use uva in the following ways:
14:37 < mibid> uva countdown <contest_id>
14:37 < mibid> uva scoreboard <contest_id>
14:37 < mibid> monitor uva <contest_id>
14:37 < mibid> stop monitoring uva

Sound good?

Revision history for this message
Keegan Carruthers-Smith (keegan-csmith) wrote : Posted in a previous version of this proposal

Maybe add a "uva set contest <contest_id>" then we can use that for
the contest_id default.

Revision history for this message
Stefano Rivera (stefanor) wrote : Posted in a previous version of this proposal

Serious stuff:
--------------

What's with the modification to identity? I thought we needed to use a string.

Query: create acm team foo
./ibid/plugins/codecontest.py:409: SADeprecationWarning: Use session.add()
  event.session.save_or_update(team)
Response: Created ACM team foo
Query: acm team foo
ERROR:scripts.ibid-plugin:Exception occured in ACMTeamManagement processor of codecontest plugin
Traceback (most recent call last):
  File "scripts/ibid-plugin", line 140, in <module>
    processor.process(event)
  File "./ibid/plugins/__init__.py", line 119, in process
    method(event, *match.groups())
  File "./ibid/plugins/codecontest.py", line 505, in acmteam_info
    u'uva_teams': uva_teams,
  File "./ibid/event.py", line 53, in addresponse
    response = response % params
ValueError: incomplete format
WARNING:plugins.unicode:Found a non-unicode string: exception
Response: I'm not feeling too well

Stylistic section of the review:
--------------------------------

> event.addresponse(u'Done')
All of those kind of things should be replaced with either a more descriptive answer ("OK, Team created"), or simply addresponse(True).

> member = event.session.query(ACMTeamMember) \
> .filter(ACMTeam.name==team_name) \
> .filter_by(name=member_name) \
It'd read better if the two filter lines where the other way around, or you used filter() for both instead of one filter(), one filter_by().

> human_join(sorted([team.name for team in teams])))
You can use a generator expression (i.e. remove the square brackets)

> uva_teams = ', and is known as %s on UVa' % human_join(uva_teams)
> else:
> uva_teams = ''
Unicode

> u'%(team_name)s has member%(plural)s %(team_members)s%(uva_teams)s'
> ...
> u'plural': plural(len(team.members), '', 's')
Bad usage of plural. It should be plural(len(team.members), 'member', 'members'). There's another one further down.

> hours = time_penalty / 3600
> time_penalty -= hours * 3600
> minutes = time_penalty / 60
Use the integer division operator //, it makes intent clearer.

> class ACMProblemScore:
That should probably be a new-style class. Applies to the others, too.
Not that it matters :)

> def __str__(self):
Should probably be __unicode__, as it returns unicode. Also applies to the other class.

> def setup(self):
> super(Uva, self).setup()
Redundant.

> u"Sorry, I'm not monitoring the scoreboard anyway"
That needs to be reworded, it doesn't parse.

review: Needs Fixing
Revision history for this message
marcog (marco-gallotta) wrote : Posted in a previous version of this proposal

> Maybe add a "uva set contest <contest_id>" then we can use that for
> the contest_id default.

When does the bot decide to drop this default? Moving on when once the contest is over makes sense for countdown, but not necessarily for scoreboard.

Revision history for this message
Stefano Rivera (stefanor) wrote : Posted in a previous version of this proposal

I agree re contest_id in config.

> 14:37 < mibid> marcog: You can use uva in the following ways:
> 14:37 < mibid> uva countdown <contest_id>
> 14:37 < mibid> uva scoreboard <contest_id>
> 14:37 < mibid> monitor uva <contest_id>
> 14:37 < mibid> stop monitoring uva

Marco: optional parameters need square brackets. i.e. uva countdown [for <contest_id>]

Revision history for this message
Keegan Carruthers-Smith (keegan-csmith) wrote : Posted in a previous version of this proposal

>> Maybe add a "uva set contest <contest_id>" then we can use that for
>> the contest_id default.
>
> When does the bot decide to drop this default? Moving on when once the contest is over makes sense for countdown, but not necessarily for scoreboard.

The contest_id is optional. So if you want the scoreboard for the
non-current contest you will have to specify it.

Also is countdown the correct name for that function? It implies it
will be doing some... counting down. "time left" is a better name.

Revision history for this message
marcog (marco-gallotta) wrote : Posted in a previous version of this proposal

> Serious stuff:
> --------------
>
> What's with the modification to identity? I thought we needed to use a string.

Oops, that slipped through the cleanup I did. r885

> Query: create acm team foo
> ./ibid/plugins/codecontest.py:409: SADeprecationWarning: Use session.add()
> event.session.save_or_update(team)
> Response: Created ACM team foo
> Query: acm team foo
> ERROR:scripts.ibid-plugin:Exception occured in ACMTeamManagement processor of
> codecontest plugin
> Traceback (most recent call last):
> File "scripts/ibid-plugin", line 140, in <module>
> processor.process(event)
> File "./ibid/plugins/__init__.py", line 119, in process
> method(event, *match.groups())
> File "./ibid/plugins/codecontest.py", line 505, in acmteam_info
> u'uva_teams': uva_teams,
> File "./ibid/event.py", line 53, in addresponse
> response = response % params
> ValueError: incomplete format
> WARNING:plugins.unicode:Found a non-unicode string: exception
> Response: I'm not feeling too well

r886

> Stylistic section of the review:
> --------------------------------
>
> > event.addresponse(u'Done')
> All of those kind of things should be replaced with either a more descriptive
> answer ("OK, Team created"), or simply addresponse(True).

r887

> > member = event.session.query(ACMTeamMember) \
> > .filter(ACMTeam.name==team_name) \
> > .filter_by(name=member_name) \
> It'd read better if the two filter lines where the other way around, or you
> used filter() for both instead of one filter(), one filter_by().

r888

> > human_join(sorted([team.name for team in teams])))
> You can use a generator expression (i.e. remove the square brackets)

r889

> > uva_teams = ', and is known as %s on UVa' %
> human_join(uva_teams)
> > else:
> > uva_teams = ''
> Unicode

r890

> > u'%(team_name)s has member%(plural)s %(team_members)s%(uva_teams)s'
> > ...
> > u'plural': plural(len(team.members), '', 's')
> Bad usage of plural. It should be plural(len(team.members), 'member',
> 'members'). There's another one further down.

r891

> > hours = time_penalty / 3600
> > time_penalty -= hours * 3600
> > minutes = time_penalty / 60
> Use the integer division operator //, it makes intent clearer.

r892

> > class ACMProblemScore:
> That should probably be a new-style class. Applies to the others, too.
> Not that it matters :)

r893

> > def __str__(self):
> Should probably be __unicode__, as it returns unicode. Also applies to the
> other class.

r984

> > def setup(self):
> > super(Uva, self).setup()
> Redundant.

r895

> > u"Sorry, I'm not monitoring the scoreboard anyway"
> That needs to be reworded, it doesn't parse.

r896 - 'I never even started monitoring the scoreboard' better?

Revision history for this message
marcog (marco-gallotta) wrote : Posted in a previous version of this proposal

> >> Maybe add a "uva set contest <contest_id>" then we can use that for
> >> the contest_id default.
> >
> > When does the bot decide to drop this default? Moving on when once the
> contest is over makes sense for countdown, but not necessarily for scoreboard.
>
> The contest_id is optional. So if you want the scoreboard for the
> non-current contest you will have to specify it.

We seem to be thinking along different paths. I'm thinking that the bot should determine the default contest_id as the current contest (if one is running), or the next upcoming contest. I thought your "set contest" option would be to temporarily override this default (e.g. if we know we're going to do this contest in 2 weeks time and don't care about the ones before then). My question was: if we do this, when do we discard the "set contest" default provided?

Example:

me: uva countdown
bot: <countdown for contest 42>
me: uva scoreboard
bot: <scoreboard for contest 41>
(contest 42 begins)
me: uva countdown
bot: <countdown for contest 42>
me: uva scoreboard
bot: <scoreboard for contest 42>
(contest 42 ends)
me: uva countdown
bot: <countdown for contest 43>
me: uva scoreboard
bot: <scoreboard for contest 42>

then...

me: uva set contest 45
me: uva countdown
bot: <countdown for contest 45>
me: uva scoreboard
bot: <scoreboard for contest 45 -- error>
(contest 45 begins)
me: uva countdown
bot: <countdown for contest 45>
me: uva scoreboard
bot: <scoreboard for contest 45 -- error>
(contest 45 ends)
me: uva countdown
bot: <countdown for contest 45> <-- this makes no sense!
me: uva scoreboard
bot: <scoreboard for contest 45> <-- but this does

or we could do:
(contest 45 ends)
me: uva countdown
bot: <countdown for contest 45>
me: uva scoreboard
bot: <scoreboard for contest 46> <-- the distinction is unexpected though

> Also is countdown the correct name for that function? It implies it
> will be doing some... counting down. "time left" is a better name.

It has aliases "remaining time" and "time remaining".

Revision history for this message
Stefano Rivera (stefanor) wrote : Posted in a previous version of this proposal
Download full text (5.9 KiB)

Query: create acm team foo
./ibid/plugins/codecontest.py:409: SADeprecationWarning: Use session.add()
  event.session.save_or_update(team)
Response: Created ACM team foo
Query: create acm team foo
ERROR:scripts.ibid-plugin:Exception occured in ACMTeamManagement processor of codecontest plugin
Traceback (most recent call last):
  File "scripts/ibid-plugin", line 140, in <module>
    processor.process(event)
  File "./ibid/plugins/__init__.py", line 119, in process
    method(event, *match.groups())
  File "./ibid/plugins/codecontest.py", line 410, in create_acmteam
    event.session.commit()
  File "/usr/lib/pymodules/python2.5/sqlalchemy/orm/session.py", line 671, in commit
    self.transaction.commit()
  File "/usr/lib/pymodules/python2.5/sqlalchemy/orm/session.py", line 378, in commit
    self._prepare_impl()
  File "/usr/lib/pymodules/python2.5/sqlalchemy/orm/session.py", line 362, in _prepare_impl
    self.session.flush()
  File "/usr/lib/pymodules/python2.5/sqlalchemy/orm/session.py", line 1354, in flush
    self._flush(objects)
  File "/usr/lib/pymodules/python2.5/sqlalchemy/orm/session.py", line 1432, in _flush
    flush_context.execute()
  File "/usr/lib/pymodules/python2.5/sqlalchemy/orm/unitofwork.py", line 261, in execute
    UOWExecutor().execute(self, tasks)
  File "/usr/lib/pymodules/python2.5/sqlalchemy/orm/unitofwork.py", line 753, in execute
    self.execute_save_steps(trans, task)
  File "/usr/lib/pymodules/python2.5/sqlalchemy/orm/unitofwork.py", line 768, in execute_save_steps
    self.save_objects(trans, task)
  File "/usr/lib/pymodules/python2.5/sqlalchemy/orm/unitofwork.py", line 759, in save_objects
    task.mapper._save_obj(task.polymorphic_tosave_objects, trans)
  File "/usr/lib/pymodules/python2.5/sqlalchemy/orm/mapper.py", line 1428, in _save_obj
    c = connection.execute(statement.values(value_params), params)
  File "/usr/lib/pymodules/python2.5/sqlalchemy/engine/base.py", line 824, in execute
    return Connection.executors[c](self, object, multiparams, params)
  File "/usr/lib/pymodules/python2.5/sqlalchemy/engine/base.py", line 874, in _execute_clauseelement
    return self.__execute_context(context)
  File "/usr/lib/pymodules/python2.5/sqlalchemy/engine/base.py", line 896, in __execute_context
    self._cursor_execute(context.cursor, context.statement, context.parameters[0], context=context)
  File "/usr/lib/pymodules/python2.5/sqlalchemy/engine/base.py", line 950, in _cursor_execute
    self._handle_dbapi_exception(e, statement, parameters, cursor, context)
  File "/usr/lib/pymodules/python2.5/sqlalchemy/engine/base.py", line 931, in _handle_dbapi_exception
    raise exc.DBAPIError.instance(statement, parameters, e, connection_invalidated=is_disconnect)
IntegrityError: (IntegrityError) column name is not unique u'INSERT INTO acmteams (name) VALUES (?)' [u'foo']
WARNING:plugins.unicode:Found a non-unicode string: exception
Response: That didn't seem to agree with me

And another:

Query: foo is uva team bar
./ibid/plugins/codecontest.py:436: SADeprecationWarning: Use session.add()
  event.session.save_or_update(uva)
Response: Righto
Query: acm team foo
Response: foo has no team members, and is known as bar o...

Read more...

review: Needs Fixing
Revision history for this message
marcog (marco-gallotta) wrote : Posted in a previous version of this proposal

> Query: create acm team foo
> ./ibid/plugins/codecontest.py:409: SADeprecationWarning: Use session.add()
> event.session.save_or_update(team)
> Response: Created ACM team foo
> Query: create acm team foo
> ERROR:scripts.ibid-plugin:Exception occured in ACMTeamManagement processor of
> codecontest plugin

r899

> Query: foo is uva team bar
> ./ibid/plugins/codecontest.py:436: SADeprecationWarning: Use session.add()
> event.session.save_or_update(uva)
> Response: Righto
> Query: acm team foo
> Response: foo has no team members, and is known as bar on UVa
> Query: delete acm team foo
> ERROR:scripts.ibid-plugin:Exception occured in ACMTeamManagement processor of
> codecontest plugin

r900

Revision history for this message
Stefano Rivera (stefanor) wrote : Posted in a previous version of this proposal

Do we want this behaviour?

Query: joe is on acm team foo
./ibid/plugins/codecontest.py:461: SADeprecationWarning: Use session.add()
  event.session.save_or_update(member)
Response: Added joe to team foo
Query: joe is on acm team foo
Response: Added joe to team foo
Query: acm team foo
Response: foo has members joe and joe, and is known as bar and baz on UVa
Query: delete joe from acm team foo
Response: Sorry, I don't know that person
Query: joe is on acm team qux
Response: Added joe to team qux

My unconfigured UVa also does this:

Query: uva countdown
ERROR:scripts.ibid-plugin:Exception occured in Uva processor of codecontest plugin
Traceback (most recent call last):
  File "scripts/ibid-plugin", line 140, in <module>
    processor.process(event)
  File "./ibid/plugins/__init__.py", line 119, in process
    method(event, *match.groups())
  File "./ibid/plugins/codecontest.py", line 654, in get_remaining_time
    time_elapsed = [h for h in etree.findall(u'body/table/tbody/tr/td/div/h1')][0].text.strip()
IndexError: list index out of range
WARNING:plugins.unicode:Found a non-unicode string: exception
Response: That didn't go down very well. Burp.

Query: uva scoreboard
ERROR:scripts.ibid-plugin:Exception occured in Uva processor of codecontest plugin
Traceback (most recent call last):
  File "scripts/ibid-plugin", line 140, in <module>
    processor.process(event)
  File "./ibid/plugins/__init__.py", line 119, in process
    method(event, *match.groups())
  File "./ibid/plugins/codecontest.py", line 665, in get_scoreboard
    problems, scores = self._get_scores(event)
  File "./ibid/plugins/codecontest.py", line 641, in _get_scores
    return (problems, scores)
UnboundLocalError: local variable 'problems' referenced before assignment
WARNING:plugins.unicode:Found a non-unicode string: exception
Response: That didn't go down very well. Burp.

Revision history for this message
marcog (marco-gallotta) wrote : Posted in a previous version of this proposal

> Do we want this behaviour?
>
> Query: joe is on acm team foo
> ./ibid/plugins/codecontest.py:461: SADeprecationWarning: Use session.add()
> event.session.save_or_update(member)
> Response: Added joe to team foo
> Query: joe is on acm team foo
> Response: Added joe to team foo
> Query: acm team foo
> Response: foo has members joe and joe, and is known as bar and baz on UVa
> Query: delete joe from acm team foo
> Response: Sorry, I don't know that person
> Query: joe is on acm team qux
> Response: Added joe to team qux

r902. It makes sense to have multiple Robert's on the same team, but I've fixed deletion.

> My unconfigured UVa also does this:

Planning to rework this based on Max/Keegan's comments anyway.

Revision history for this message
marcog (marco-gallotta) wrote : Posted in a previous version of this proposal

Alrighty, the selection of contest id is now handled automatically unless a contest id is specified on a per-command basis. Monitoring checks for the running contest only (I assume no parallel contests -- note that this isn't always true, but very rare). It's still not very robust to poor user commands (e.g. an unknown contest id will result in an unhandled exception).

Revision history for this message
Stefano Rivera (stefanor) wrote : Posted in a previous version of this proposal

First pass

I'm glad to see you've shortened long lines :)

However, be careful with string types. If you do u'a' 'b', you are concatenating different types of string. That's ok, if ugly, and the result will be unicode, which is what you want. However: r'\a' '\r'. Only the first one is a raw string the second one is a literal CR. I'm talking about diff lines 21x.

== Major ==

62 - autoload = False
63 + #autoload = False

I don't think I need to say anything. And there's more than one.

== Minor ==

44 +from sqlalchemy import Table, Column, ForeignKey
45 +from sqlalchemy.orm import relation
46 +from sqlalchemy.exceptions import InvalidRequestError

I think you can get those from ibid.db

83 + return u'%(user)s (%(usaco_user)s on USACO) %(section)s and ' \
84 + 'last logged in %(days)s ago' % {

Inside brackets (of any kind), you don't need a continuation character (\). There are lots of those.

500 +class Contest:

Newstyle, please.

533 + u'start': self.start.strftime('%d %b %Y %H:%M:%S'),

There's a helper function for formatting times in a bot-wide configurable format.

538 + return mktime(self.start.timetuple()) - mktime(other.start.timetuple())

I think datetimes can be subtracted from each other (giving a timedelta). But obviously this works.

review: Needs Fixing
Revision history for this message
marcog (marco-gallotta) wrote : Posted in a previous version of this proposal

> However, be careful with string types. If you do u'a' 'b', you are

r910

> 62 - autoload = False
> 63 + #autoload = False

r911

> 44 +from sqlalchemy import Table, Column, ForeignKey
> 45 +from sqlalchemy.orm import relation
> 46 +from sqlalchemy.exceptions import InvalidRequestError
>
> I think you can get those from ibid.db

r912. InvalidRequestError was not there, but I added it. That ok?

> Inside brackets (of any kind), you don't need a continuation character (\).
> There are lots of those.

r913

> 500 +class Contest:
>
> Newstyle, please.

r914

> 533 + u'start': self.start.strftime('%d %b %Y %H:%M:%S'),
>
> There's a helper function for formatting times in a bot-wide configurable
> format.

r915. Nice, it even converts to local tz which I was planning on adding later. :)

> 538 + return mktime(self.start.timetuple()) -
> mktime(other.start.timetuple())
>
> I think datetimes can be subtracted from each other (giving a timedelta). But
> obviously this works.

In [1]: datetime.now() - datetime.now()
Out[1]: datetime.timedelta(-1, 86399, 999985)

I don't see a way to get the sign from that that will always work, besides adding days+hours+minutes+... (which is worse than what I currently have)

Revision history for this message
Stefano Rivera (stefanor) wrote : Posted in a previous version of this proposal

r'\s+([\*xts\.e0-9 ]+?)'
I don't think * or . must be escaped inside square brackets.

296 + def __repr(self):

It returns unicode, so it should be a __unicode__ (besides the obvious fact that it's currently incorrectly named). There are a few __reper__s.

I'm running out of things to say. Now I just need to test it.

Revision history for this message
marcog (marco-gallotta) wrote : Posted in a previous version of this proposal

> r'\s+([\*xts\.e0-9 ]+?)'
> I don't think * or . must be escaped inside square brackets.

r917

> It returns unicode, so it should be a __unicode__ (besides the obvious fact

r916

Revision history for this message
Stefano Rivera (stefanor) wrote : Posted in a previous version of this proposal

Somewhere around line 725, reformat badly formatted times (zero-padding)

Revision history for this message
marcog (marco-gallotta) wrote : Posted in a previous version of this proposal

> Somewhere around line 725, reformat badly formatted times (zero-padding)

r918

Revision history for this message
Stefano Rivera (stefanor) wrote : Posted in a previous version of this proposal

Can you rename acmteams to acmteam.

Query: acm teams
Response: ACM teams:

Query: uva contests
/usr/lib/pymodules/python2.6/html5lib/inputstream.py:367: DeprecationWarning: object.__init__() takes no parameters
  str.__init__(self, value)
Response: UVa contests: A Canadian Contest (248) starts 2010-01-09 10:00:00 SAST and lasts 5 hours, CUET Inter-university Programming Contest (247) starts 2010-01-09 17:00:00 SAST and lasts 5 hours, A Bangladeshi Contest (246) starts 2009-12-19 10:00:00 SAST and lasts 5 hours, Alkhawarizmi Programming Contest 2009 (245) starts 2009-12-05 11:00:00 SAST and lasts 5 hours, World Finals Warmup II (244) starts 2010-01-23 16:00:00 SAST and lasts 5 hours, World Finals Warmup I (243) starts 2010-01-16 11:00:00 SAST and lasts 5 hours 15 minutes, Wuhan Regional Semilive (242) starts 2009-11-02 11:00:00 SAST and lasts 5 hours, IIUPC 2009 (241) starts 2009-10-21 11:00:00 SAST and lasts 5 hours, Another Regional Contest (240) starts 2009-11-14 09:00:00 SAST and lasts 5 hours, Nordic Collegiate Programming Contest NCPC 2009 (239) starts 2009-10-03 11:00:00 SAST and lasts 5 hours, Waterloo ACM Programming Contest Fall 2 (238) starts 2009-10-04 19:00:00 SAST and lasts 3 hours, Waterloo ACM Programming Contest Fall 1 (237) starts 2009-09-27 19:00:00 SAST and lasts 3 hours, Dhaka regional Semi-live (236) starts 2009-10-24 13:00:00 SAST and lasts 5 hours, Once Again! A Bangladeshi Contest (235) starts 2009-08-22 11:00:00 SAST and lasts 5 hours 45 minutes, Regional Warmup 1 (234) starts 2009-09-13 15:00:00 SAST and lasts 5 hours, ULM Local Contest (233) starts 2009-07-18 11:00:00 SAST and lasts 5 hours, XXIII Colombian Programming Contest (232) starts 2009-09-05 21:00:00 SAST and lasts 5 hours, Yet another Bangladeshi Contest (231) starts 2009-08-01 11:00:00 SAST and lasts 5 hours, One more ICPC Regional Contest (230) starts 2009-11-07 11:00:00 SAST and lasts 5 hours, ACM ICPC South America Regional Contest (229) starts 2009-10-24 22:00:00 SAST and lasts 5 hours, CUPCAM 2009 (228) starts 2009-10-17 10:00:00 SAST and lasts 5 hours, Brazilian National Contest 2009 (227) starts 2009-09-19 22:00:00 SAST and lasts 5 hours, Waterloo Local Spring 2009 (226) starts 2009-06-13 20:00:00 SAST and lasts 4 hours, The first contest of the new season (225) starts 2009-05-09 11:00:00 SAST and lasts 5 hours, VII Programming Olympiads in Murcia (224) starts 2009-05-23 09:30:00 SAST and lasts 4 hours, Another high quality contest (223) starts 2009-02-21 11:00:00 SAST and lasts 5 hours, Pre-warmup contest (222) starts 2009-02-14 11:00:00 SAST and lasts 3 hours, World Finals Warmup II (221) starts 2009-04-05 10:00:00 SAST and lasts 1 day, World Finals Warmup I (220) starts 2009-03-29 16:00:00 SAST and lasts 1 day and Contest of Newbies V (219) starts 2008-12-27 14:00:00 SAST and lasts 4 hours

That looks a little long for IRC, you may need some paging. But quite honestly I wouldn't worry about it for now.

Revision history for this message
marcog (marco-gallotta) wrote : Posted in a previous version of this proposal

> Can you rename acmteams to acmteam.

r919

> That looks a little long for IRC, you may need some paging. But quite honestly
> I wouldn't worry about it for now.

It gets truncated and we usually don't care about the ones way in the past.

Revision history for this message
Stefano Rivera (stefanor) wrote : Posted in a previous version of this proposal

I'd still like something to deal with the null-length acm team list response, but I approve

review: Approve
Revision history for this message
marcog (marco-gallotta) wrote : Posted in a previous version of this proposal

> I'd still like something to deal with the null-length acm team list response,
> but I approve

Oh, missed that in your last comment. r920

Revision history for this message
Stefano Rivera (stefanor) :
review: Approve
Revision history for this message
Michael Gorven (mgorven) wrote :

Looks fine. Is authorisation not needed?
 review approve

review: Approve
Revision history for this message
Jonathan Hitchcock (vhata) :
review: Abstain
Revision history for this message
marcog (marco-gallotta) wrote :

> Is authorisation not needed?

For what?

Revision history for this message
Stefano Rivera (stefanor) wrote :

You have two "from ibid.db import" lines. merge em.

review: Needs Fixing
Revision history for this message
Max Rabkin (max-rabkin) wrote :

u'UVa team %s is already linke to an ACM team'

<Taejo> tibid: create acm team father
<tibid> Taejo: Created ACM team father
<Taejo> tibid: Taejo is on acm team mother
<tibid> Taejo: Added Taejo to team mother
<Taejo> tibid: delete Taejo from acm team father
<tibid> Taejo: Removed Taejo from team father

Expected: "Taejo is not on team father"

Taejo> tibid: I am on acm team father
<tibid> Taejo: Huh?

Expected: "Added Taejo to team father"; I won't block on this.

The human_ranking function should probably be called utils.ordinal or something like that.

"type += ' '" is clearer than "type = '%s ' % type'" (line 663)

<Taejo> tibid: past uva contests
<tibid> Taejo: Past UVa contests: A Canadian Contest (248) starts 2010-01-09 10:00:00 SAST and lasts 5 hours

Expected "started".

Accept "(?:up)?coming" rather than just "coming".

review: Needs Fixing
Revision history for this message
Stefano Rivera (stefanor) wrote :

Sorry, API change means you neeed to update your help-related bits.

review: Needs Fixing

Unmerged revisions

922. By marcog

Merge trunk

921. By marcog

spelling

920. By marcog

Handle case of no acm teams

919. By marcog

Rename acmteams to acmteam

918. By marcog

Pad countdown times with 0's

917. By marcog

Fix usaco results regex (don't escape . and * inside [])

916. By marcog

__repr__->__unicode__

915. By marcog

Use helper function for time formatting

914. By marcog

Newstyle class for Contest

913. By marcog

Remvoe unneccessary \ characters

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'ibid/db/__init__.py'
2--- ibid/db/__init__.py 2010-02-05 15:58:06 +0000
3+++ ibid/db/__init__.py 2010-02-13 21:24:14 +0000
4@@ -11,7 +11,8 @@
5 from sqlalchemy.sql import func
6 from sqlalchemy.ext.declarative import declarative_base as _declarative_base
7
8-from sqlalchemy.exceptions import IntegrityError, SADeprecationWarning
9+from sqlalchemy.exceptions import IntegrityError, SADeprecationWarning, \
10+ InvalidRequestError
11
12 metadata = _MetaData()
13 Base = _declarative_base(metadata=metadata)
14
15=== modified file 'ibid/event.py'
16--- ibid/event.py 2010-01-17 20:17:23 +0000
17+++ ibid/event.py 2010-02-13 21:24:14 +0000
18@@ -49,7 +49,7 @@
19 u'difficulties with translation later.',
20 SyntaxWarning, stacklevel=2)
21
22- if isinstance(response, basestring) and params:
23+ if isinstance(response, basestring) and (params or params in (u'', '')):
24 response = response % params
25
26 if isinstance(response, str):
27
28=== modified file 'ibid/plugins/codecontest.py'
29--- ibid/plugins/codecontest.py 2010-01-25 18:31:04 +0000
30+++ ibid/plugins/codecontest.py 2010-02-13 21:24:14 +0000
31@@ -1,20 +1,31 @@
32 # Copyright (c) 2010, Marco Gallotta
33 # Released under terms of the MIT/X/Expat Licence. See COPYING for details.
34
35+from dateutil.relativedelta import relativedelta
36 import re
37+from time import mktime
38 from urllib2 import HTTPError, URLError
39
40+from dateutil.parser import parse
41 from urllib import urlencode
42
43-from ibid.config import Option
44-from ibid.db import eagerload
45+from ibid.config import Option, IntOption
46+from ibid.db import Base, eagerload
47 from ibid.db.models import Account, Attribute, Identity
48-from ibid.plugins import Processor, match, auth_responses
49-from ibid.utils import cacheable_download
50+from ibid.db.types import IbidUnicode, Integer
51+from ibid.db.versioned_schema import VersionedSchema
52+from ibid.plugins import Processor, match, periodic, auth_responses
53+from ibid.utils import cacheable_download, plural, human_join, format_date
54 from ibid.utils.html import get_html_parse_tree
55-
56-help = {u'usaco': u'Query USACO sections, divisions and more. Since this info is private, users are required to provide their USACO password when linking their USACO account to their ibid account and only linked accounts can be queried. Your password is used only to confirm that the account is yours and is discarded immediately.'}
57-
58+from ibid.db import Table, Column, ForeignKey, relation, InvalidRequestError
59+
60+help = {}
61+
62+help[u'usaco'] = u'Query USACO sections, divisions and more. Since this info ' \
63+ u'is private, users are required to provide their USACO password when ' \
64+ u'linking their USACO account to their ibid account and only linked ' \
65+ u'accounts can be queried. Your password is used only to confirm ' \
66+ u'that the account is yours and is discarded immediately.'
67 class UsacoException(Exception):
68 def __init__(self, msg):
69 self.msg = msg
70@@ -37,9 +48,13 @@
71 autoload = False
72
73 def _login(self, user, password):
74- params = urlencode({'NAME': user.encode('utf-8'), 'PASSWORD': password.encode('utf-8')})
75+ params = urlencode({
76+ 'NAME': user.encode('utf-8'),
77+ 'PASSWORD': password.encode('utf-8')
78+ })
79 try:
80- etree = get_html_parse_tree(u'http://ace.delos.com/usacogate', data=params, treetype=u'etree')
81+ etree = get_html_parse_tree(u'http://ace.delos.com/usacogate',
82+ data=params, treetype=u'etree')
83 except URLError:
84 raise UsacoException(u'Sorry, USACO (or my connection?) is down')
85 for font in etree.getiterator(u'font'):
86@@ -66,7 +81,8 @@
87 if tds[5] == u'DONE':
88 section = u'has completed USACO training'
89 if tds[0] and tds[0].lower() == usaco_user:
90- return u'%(user)s (%(usaco_user)s on USACO) %(section)s and last logged in %(days)s ago' % {
91+ return u'%(user)s (%(usaco_user)s on USACO) %(section)s and ' \
92+ u'last logged in %(days)s ago' % {
93 'user': user,
94 'usaco_user': usaco_user,
95 'days': tds[3],
96@@ -119,11 +135,14 @@
97 .filter(Identity.source == event.source) \
98 .first()
99 if account is None:
100- raise UsacoException(u'Sorry, %s has not been linked to a USACO account yet' % user)
101+ raise UsacoException(u'Sorry, %s has not been linked to a '
102+ u'USACO account yet' % user)
103
104- usaco_account = [attr.value for attr in account.attributes if attr.name == 'usaco_account']
105+ usaco_account = [attr.value for attr in account.attributes
106+ if attr.name == 'usaco_account']
107 if len(usaco_account) == 0:
108- raise UsacoException(u'Sorry, %s has not been linked to a USACO account yet' % user)
109+ raise UsacoException(u'Sorry, %s has not been linked to a USACO '
110+ u'account yet' % user)
111 return usaco_account[0]
112
113 def _get_usaco_users(self, event):
114@@ -169,21 +188,26 @@
115
116 params = urlencode({'id': usaco_user.encode('utf-8'), 'search': 'SEARCH'})
117 try:
118- etree = get_html_parse_tree(u'http://ace.delos.com/showdiv', data=params, treetype=u'etree')
119+ etree = get_html_parse_tree(u'http://ace.delos.com/showdiv',
120+ data=params, treetype=u'etree')
121 except URLError:
122 event.addresponse(u'Sorry, USACO (or my connection?) is down')
123- division = [b.text for b in etree.getiterator(u'b') if b.text and usaco_user in b.text][0]
124+ division = [b.text for b in etree.getiterator(u'b')
125+ if b.text and usaco_user in b.text][0]
126 if division.find(u'would compete') != -1:
127- event.addresponse(u'%(user)s (%(usaco_user)s on USACO) has not competed in a USACO before',
128+ event.addresponse(u'%(user)s (%(usaco_user)s on USACO) has not '
129+ u'competed in a USACO before',
130 {u'user': user, u'usaco_user': usaco_user})
131 matches = re.search(r'(\w+) Division', division)
132 division = matches.group(1).lower()
133- event.addresponse(u'%(user)s (%(usaco_user)s on USACO) is in the %(division)s division',
134+ event.addresponse(u'%(user)s (%(usaco_user)s on USACO) is in '
135+ u'the %(division)s division',
136 {u'user': user, u'usaco_user': usaco_user, u'division': division})
137
138 def _redact(self, event, term):
139 for type in ['raw', 'deaddressed', 'clean', 'stripped']:
140- event['message'][type] = re.sub(r'(.*)(%s)' % re.escape(term), r'\1[redacted]', event['message'][type])
141+ event['message'][type] = re.sub(r'(.*)(%s)' % re.escape(term),
142+ r'\1[redacted]', event['message'][type])
143
144 @match(r'^(\S+)\s+(?:is|am)\s+(\S+)\s+on\s+usaco(?:\s+password\s+(\S+))?$')
145 def usaco_account(self, event, user, usaco_user, password):
146@@ -191,16 +215,19 @@
147 self._redact(event, password)
148
149 if event.public and password:
150- event.addresponse(u"Giving your password in public is bad! Please tell me that again in a private message.")
151+ event.addresponse(u'Giving your password in public is bad! '
152+ u'Please tell me that again in a private message.')
153 return
154
155 if not event.account:
156- event.addresponse(u'Sorry, you need to create an account with me first (type "usage accounts" to see how)')
157+ event.addresponse(u'Sorry, you need to create an account with '
158+ u'me first (type "usage accounts" to see how)')
159 return
160 admin = auth_responses(event, u'usacoadmin')
161 if user.lower() == 'i':
162 if password is None and not admin:
163- event.addresponse(u'Sorry, I need your USACO password to verify your account')
164+ event.addresponse(u'Sorry, I need your USACO password to '
165+ u'verify your account')
166 if password and not self._check_login(user, password):
167 event.addresponse(u'Sorry, that account is invalid')
168 return
169@@ -226,7 +253,8 @@
170 event.addresponse(e)
171 return
172
173- usaco_account = [attr for attr in account.attributes if attr.name == u'usaco_account']
174+ usaco_account = [attr for attr in account.attributes
175+ if attr.name == u'usaco_account']
176 if usaco_account:
177 usaco_account[0].value = usaco_user
178 else:
179@@ -234,7 +262,7 @@
180 event.session.save_or_update(account)
181 event.session.commit()
182
183- event.addresponse(u'Done')
184+ event.addresponse(True)
185
186 @match(r'^usaco\s+(\S+)\s+results(?:\s+for\s+(.+))?$')
187 def usaco_results(self, event, contest, user):
188@@ -249,18 +277,19 @@
189
190 url = u'http://ace.delos.com/%sresults' % contest.upper()
191 try:
192- filename = cacheable_download(url, u'usaco/results_%s' % contest.upper(), timeout=30)
193+ filename = cacheable_download(url,
194+ u'usaco/results_%s' % contest.upper(), timeout=30)
195 except HTTPError:
196 event.addresponse(u"Sorry, the results for %s aren't released yet", contest)
197 except URLError:
198- event.addresponse(u"Sorry, I couldn't fetch the USACO results. Maybe USACO is down?")
199+ event.addresponse(u"Sorry, I couldn't fetch the USACO results. "
200+ u"Maybe USACO is down?")
201
202 if user is not None:
203 users = {usaco_user: user.lower()}
204 else:
205 try:
206 users = self._get_usaco_users(event)
207- print users
208 except UsacoException, e:
209 event.addresponse(e)
210 return
211@@ -275,8 +304,16 @@
212 if d in line.lower():
213 division = index
214 # Example results line:
215- # 2010 POL Jakub Pachocki meret1 ***** ***** 270 ***** ***** * 396 ***** ***** ** 324 1000
216- matches = re.match(r'^\s*(\d{4})\s+([A-Z]{3})\s+(.+?)\s+(\S+\d)\s+([\*xts\.e0-9 ]+?)\s+(\d+)\s*$', line)
217+ # 2010 POL Jakub Pachocki meret1 \
218+ # ***** ***** 270 ***** ***** * 396 ***** ***** ** 324 1000
219+ matches = re.match(
220+ r'^\s*(\d{4})' # Year
221+ r'\s+([A-Z]{3})' # Country code
222+ r'\s+(.+?)' # Name
223+ r'\s+(\S+\d)' # Account name
224+ r'\s+([*xts.e0-9 ]+?)' # Scores
225+ r'\s+(\d+)' # Total score
226+ r'\s*$', line)
227 if matches:
228 year = matches.group(1)
229 country = matches.group(2)
230@@ -291,7 +328,8 @@
231 match = True
232 users[usaco_user] = user
233 if match:
234- results[division].append((year, country, name, usaco_user, scores, total))
235+ results[division].append((year, country, name,
236+ usaco_user, scores, total))
237 count += 1
238
239 response = []
240@@ -309,7 +347,8 @@
241 division_string = u' in the %s division' % division.title()
242 else:
243 division_string = u''
244- response.append(u'%(user)s scored %(total)s%(division)s (%(scores)s)' % {
245+ response.append(u'%(user)s scored %(total)s%(division)s '
246+ u'(%(scores)s)' % {
247 u'user': user_string,
248 u'total': result[5],
249 u'scores': result[4],
250@@ -323,9 +362,599 @@
251 u'contest': contest,
252 })
253 else:
254- event.addresponse(u"Sorry, I don't know anyone that entered %s", contest)
255- return
256-
257- event.addresponse(u'\n'.join(response), conflate=False)
258+ event.addresponse(u"Sorry, I don't know anyone "
259+ u"that entered %s", contest)
260+ return
261+
262+ event.addresponse(u'\n'.join(response), conflate=False)
263+
264+
265+class UVATeam(Base):
266+ __table__ = Table('uvateams', Base.metadata,
267+ Column('id', Integer, primary_key=True),
268+ Column('acm_id', Integer, ForeignKey('acmteams.id'), nullable=False),
269+ Column('name', IbidUnicode(64, case_insensitive=True), unique=True,
270+ nullable=False, index=True),
271+ useexisting=True)
272+
273+ __table__.versioned_schema = VersionedSchema(__table__, 1)
274+
275+ def __init__(self, acm_id, name):
276+ self.acm_id = acm_id
277+ self.name = name
278+
279+ def __unicode__(self):
280+ return u'<UVATeam %s is ACMTeam %d>' % (self.name, self.acm_id)
281+
282+
283+class ACMTeamMember(Base):
284+ __table__ = Table('acmteammembers', Base.metadata,
285+ Column('id', Integer, primary_key=True),
286+ Column('team_id', Integer, ForeignKey('acmteams.id'), nullable=False,
287+ index=True),
288+ Column('name', IbidUnicode(64, case_insensitive=True), nullable=False),
289+ useexisting=True)
290+
291+ __table__.versioned_schema = VersionedSchema(__table__, 1)
292+
293+ def __init__(self, team_id, name):
294+ self.team_id = team_id
295+ self.name = name
296+
297+ def __unicode__(self):
298+ return u'<ACMTeamMember %s in %s>' % (self.name, self.team_id)
299+
300+
301+class ACMTeam(Base):
302+ __table__ = Table('acmteams', Base.metadata,
303+ Column('id', Integer, primary_key=True),
304+ Column('name', IbidUnicode(64, case_insensitive=True), unique=True,
305+ index=True), useexisting=True)
306+
307+ __table__.versioned_schema = VersionedSchema(__table__, 1)
308+
309+ uvateams = relation(UVATeam, backref='team', cascade='all,delete')
310+ members = relation(ACMTeamMember, backref='team', cascade='all,delete')
311+
312+ def __init__(self, name=None):
313+ self.name = name
314+
315+ def __unicode__(self):
316+ return u'<ACMTeam %s>' % self.name
317+
318+
319+help[u'acmteam'] = u'Manage ACM teams by and their members and link them ' \
320+ u'to accounts on various judge sites (currently only UVa is supported).'
321+class ACMTeamManagement(Processor):
322+ """create acm team <team_name>
323+ delete acm team <team_name>
324+ <team_name> is uva team <uva_name>
325+ <member_name> is on acm team <team_name>
326+ delete <member_name> from acm team <team_name>
327+ acm teams
328+ acm team <team_name>"""
329+
330+ feature = 'acmteam'
331+ priority = -10 # Alternate syntax 'remove foo' clashes with auth
332+ autoload = False
333+
334+ @match(r'^create\s+acm\s+team\s+(.+)$')
335+ def create_acmteam(self, event, name):
336+ team = event.session.query(ACMTeam) \
337+ .filter_by(name=name) \
338+ .first()
339+ if team:
340+ event.addresponse(u'I already know about a team called %s', name)
341+ return
342+ team = ACMTeam(name)
343+ event.session.save_or_update(team)
344+ event.session.commit()
345+ event.addresponse(u'Created ACM team %s', name)
346+
347+ @match('^(?:delete|remove)\s+acm\s+team\s+(.+)$')
348+ def delete_acmteam(self, event, name):
349+ try:
350+ team = event.session.query(ACMTeam) \
351+ .filter_by(name=name) \
352+ .one()
353+ except InvalidRequestError:
354+ event.addresponse(u"Sorry, I don't know the team %s", name)
355+ return
356+ event.session.delete(team)
357+ event.session.commit()
358+ event.addresponse(True)
359+
360+ @match(r'^(.+)\s+is\s+uva\s+team\s+(.+)$')
361+ def link_uvateam(self, event, team_name, uva_name):
362+ try:
363+ team = event.session.query(ACMTeam) \
364+ .filter_by(name=team_name) \
365+ .one()
366+ except InvalidRequestError:
367+ event.addresponse(u"Sorry, I don't know the team %s", team_name)
368+ return
369+ uvateam = event.session.query(UVATeam) \
370+ .filter_by(name=uva_name) \
371+ .first()
372+ if uvateam:
373+ event.addresponse(u'UVa team %s is already linke to an ACM team',
374+ uva_name)
375+ return
376+ uva = UVATeam(team.id, uva_name)
377+ event.session.save_or_update(uva)
378+ event.session.commit()
379+ event.addresponse(True)
380+
381+ @match(r'^(.+)\s+is\s+[io]n\s+acm\s+team\s+(.+)$')
382+ def add_acmmember(self, event, member_name, team_name):
383+ try:
384+ team = event.session.query(ACMTeam) \
385+ .filter_by(name=team_name) \
386+ .one()
387+ except InvalidRequestError:
388+ event.addresponse(u"Sorry, I don't know the team %s", team_name)
389+ return
390+ member = ACMTeamMember(team.id, member_name)
391+ event.session.save_or_update(member)
392+ event.session.commit()
393+ event.addresponse(u'Added %(member)s to team %(team)s', {
394+ u'member': member_name,
395+ u'team': team_name,
396+ })
397+
398+ @match(r'^(?:delete|remove)\s+(.+)\s+from\s+acm\s+team\s+(.+)$')
399+ def remove_acmmember(self, event, member_name, team_name):
400+ member = event.session.query(ACMTeamMember) \
401+ .filter_by(name=member_name) \
402+ .filter(ACMTeam.name==team_name) \
403+ .first()
404+ if not member:
405+ event.addresponse(u"Sorry, I don't know that person")
406+ return
407+ event.session.delete(member)
408+ event.session.commit()
409+ event.addresponse(u'Removed %(member)s from team %(team)s', {
410+ u'member': member_name,
411+ u'team': team_name,
412+ })
413+
414+ @match(r'^(?:list\s+)?acm\s+teams$')
415+ def list_acmteams(self, event):
416+ teams = event.session.query(ACMTeam).all()
417+ if teams:
418+ event.addresponse(u'ACM teams: %s',
419+ human_join(sorted(team.name for team in teams)))
420+ else:
421+ event.addresponse(u'No ACM teams registered with me')
422+
423+ @match(r'^acm\s+team\s+(.+)$')
424+ def acmteam_info(self, event, name):
425+ try:
426+ team = event.session.query(ACMTeam) \
427+ .filter_by(name=name) \
428+ .one()
429+ except InvalidRequestError:
430+ event.addresponse(u"Sorry, I don't know the team %s", name)
431+ return
432+ uva_teams = [t.name for t in team.uvateams]
433+ if uva_teams:
434+ uva_teams = u', and is known as %s on UVa' % human_join(uva_teams)
435+ else:
436+ uva_teams = u''
437+ members = [m.name for m in team.members]
438+ if members:
439+ event.addresponse(u'%(team_name)s has %(plural)s '
440+ '%(team_members)s%(uva_teams)s', {
441+ u'team_name': name,
442+ u'team_members': human_join(sorted(m.name for m in team.members)),
443+ u'uva_teams': uva_teams,
444+ u'plural': plural(len(team.members), 'member', 'members')
445+ })
446+ else:
447+ event.addresponse(u'%(team_name)s has no team members%(uva_teams)s', {
448+ u'team_name': name,
449+ u'uva_teams': uva_teams,
450+ })
451+
452+
453+def time_penalty_str(time_penalty):
454+ hours = time_penalty // 3600
455+ time_penalty -= hours * 3600
456+ minutes = time_penalty // 60
457+ time_penalty -= minutes * 60
458+ seconds = time_penalty
459+ return u'%02d:%02d:%02d' % (hours, minutes, seconds)
460+
461+
462+class ACMProblemScore(object):
463+ def __init__(self, correct, submissions, time):
464+ self.correct = correct
465+ self.submissions = submissions
466+ self.time = time
467+
468+ def points(self):
469+ return int(self.correct)
470+
471+ def time_penalty(self):
472+ if self.correct:
473+ return self.time + 20*60*(self.submissions-1)
474+ return 0
475+
476+ def __unicode__(self):
477+ if self.correct:
478+ time_penalty = time_penalty_str(self.time_penalty())
479+ else:
480+ time_penalty = u'--'
481+ return u'(%(submissions)d) %(time_penalty)s' % {
482+ u'submissions': self.submissions,
483+ u'time_penalty': time_penalty
484+ }
485+
486+
487+class ACMScore(object):
488+ def __init__(self, problem_scores):
489+ self.problem_scores = problem_scores
490+
491+ def points(self):
492+ return sum(s.points() for s in self.problem_scores.itervalues())
493+
494+ def time_penalty(self):
495+ return sum(s.time_penalty() for s in self.problem_scores.itervalues())
496+
497+ def __cmp__(self, b):
498+ if self.points() != b.points():
499+ return -(self.points() - b.points())
500+ return self.time_penalty() - b.time_penalty()
501+
502+ def __unicode__(self):
503+ return u'%(problem_scores)s %(points)d %(time_penalty)s' % {
504+ u'problem_scores': u' '.join(str(s) for p, s in
505+ sorted(self.problem_scores.iteritems())),
506+ u'points': self.points(),
507+ u'time_penalty': time_penalty_str(self.time_penalty()),
508+ }
509+
510+
511+class Contest(object):
512+ def __init__(self, id, name, start, end, dayfirst=False, yearfirst=False):
513+ self.id = int(id)
514+ self.name = name
515+ self.start = parse(start)
516+ self.end = parse(end)
517+
518+ def duration(self):
519+ time = relativedelta(self.end, self.start)
520+
521+ if time.days == 0:
522+ days = u''
523+ else:
524+ days = u'%d %s ' % (time.days, plural(time.days, u'day', u'days'))
525+
526+ if time.hours == 0:
527+ hours = u''
528+ else:
529+ hours = u'%d %s ' % (time.hours,
530+ plural(time.hours, u'hour', u'hours'))
531+
532+ if time.minutes == 0:
533+ minutes = u''
534+ else:
535+ minutes = u'%d %s ' % (time.minutes,
536+ plural(time.minutes, u'minute', u'minutes'))
537+
538+ return (u'%s%s%s' % (days, hours, minutes)).strip()
539+
540+ def __unicode__(self):
541+ return u'%(name)s (%(id)d) starts %(start)s and lasts %(duration)s' % {
542+ u'name': self.name,
543+ u'id': self.id,
544+ u'start': format_date(self.start),
545+ u'duration': self.duration(),
546+ }
547+
548+ def __cmp__(self, other):
549+ return mktime(self.start.timetuple()) - mktime(other.start.timetuple())
550+
551+
552+def ranking(scores, team):
553+ for position, (t, score) in enumerate(sorted(
554+ scores.iteritems(), key=lambda (k,v): (v,k))):
555+ if t == team:
556+ return position + 1
557+
558+
559+def human_ranking(ranking):
560+ if ranking % 10 == 1 and ranking % 100 != 11:
561+ return u'%dst' % ranking
562+ if ranking % 10 == 2 and ranking % 100 != 12:
563+ return u'%dnd' % ranking
564+ if ranking % 10 == 3 and ranking % 100 != 13:
565+ return u'%drd' % ranking
566+ return u'%dth' % ranking
567+
568+
569+help[u'uva'] = u'Monitor the scoreboard of an ACM contest hosted on the UVa ' \
570+ u'online judge. To add teams to monitor, first use the "acmteam" ' \
571+ u'feature to create the teams and link them to UVa accounts. The ' \
572+ u'<contest_id> option is the contest=? parameter in the UVa contest ' \
573+ u'you would like to monitor.'
574+class Uva(Processor):
575+ """[past|running|coming|all] uva contests
576+ [previous|next] uva contest
577+ uva countdown [<contest_id>]
578+ uva scoreboard [<contest_id>]
579+ monitor uva
580+ stop monitoring uva"""
581+
582+ feature = u'uva'
583+ autoload = False
584+
585+ scores_url = u'http://uva.onlinejudge.org/index2.php?option=' \
586+ u'com_onlinejudge&Itemid=13&page=show_contest_standings&contest=%s&fullscreen=1'
587+ uva_monitor_period = IntOption(u'uva_monitor_period',
588+ u'Delay period between monitor calls', 60)
589+
590+ teams = {}
591+ monitors = []
592+
593+ def _get_contests(self, type='all'):
594+ if type == 'all':
595+ contests = []
596+ for t in ('past', 'running', 'coming'):
597+ contests.extend(self._get_contests(t))
598+ return contests
599+ elif type == 'past':
600+ item_id = 13
601+ elif type == 'running':
602+ item_id = 12
603+ elif type == 'coming':
604+ item_id = 11
605+ else:
606+ raise Exception(u'Unexpected contest type: %s' % type)
607+
608+ url = u'http://uva.onlinejudge.org/index.php?option=' \
609+ u'com_onlinejudge&Itemid=%s' % item_id
610+ etree = get_html_parse_tree(url, treetype=u'etree')
611+
612+ contests = []
613+ for tr in etree.findall(u'body/div/div/div/div/div/div/table/tbody/tr'):
614+ if tr.get(u'class') in (u'sectiontableentry1', u'sectiontableentry2'):
615+ tds = [td for td in tr.getiterator(u'td')]
616+ id = tds[0].text
617+ name = tds[2].find(u'a').text
618+ start = tds[3].text
619+ end = tds[4].text
620+ contest = Contest(id, name, start, end, dayfirst=False, yearfirst=True)
621+ contests.append(contest)
622+
623+ return contests
624+
625+ def _get_recent_contest(self):
626+ contests = sorted(self._get_contests(u'past'))
627+ if contests:
628+ return contests[-1]
629+ return None
630+
631+ def _get_running_contest(self):
632+ contests = sorted(self._get_contests(u'running'))
633+ if contests:
634+ return contests[0]
635+ return None
636+
637+ def _get_next_contest(self):
638+ contests = sorted(self._get_contests(u'coming'))
639+ if contests:
640+ return contests[-1]
641+ return None
642+
643+ def _get_running_or_next_or_recent_contest(self):
644+ return self._get_running_contest() or self._get_next_contest() \
645+ or self._get_recent_contest()
646+
647+ def _get_running_or_recent_contest(self):
648+ return self._get_running_contest() or self._get_recent_contest()
649+
650+ @match(r'^(?:(past|running|coming|all|previous|next)\s+)?uva\s+contests?$')
651+ def get_contests(self, event, type):
652+ if not type:
653+ type = u'all'
654+ if type == u'previous':
655+ contests = [self._get_recent_contest()]
656+ elif type == u'next':
657+ contests = [self._get_next_contest()]
658+ else:
659+ contests = self._get_contests(type)
660+ if type == u'all':
661+ type = u''
662+ else:
663+ type = '%s ' % type
664+ if contests and contests[0]:
665+ event.addresponse(u'%(type)sUVa %(contest_s)s: %(contests)s', {
666+ u'type': type.title(),
667+ u'contest_s': plural(len(contests), u'contest', u'contests'),
668+ u'contests': human_join(map(unicode, contests)),
669+ })
670+ else:
671+ event.addresponse(u'No %(type)sUVa %(contest_s)s', {
672+ u'type': type,
673+ u'contest_s': plural((type not in (u'previous', 'next')) * 2,
674+ u'contest', u'contests'),
675+ })
676+
677+ def _get_scores(self, event, contest_id):
678+ url = self.scores_url % contest_id
679+ etree = get_html_parse_tree(url, treetype=u'etree')
680+
681+ teams = {}
682+ for team in event.session.query(UVATeam).all():
683+ teams[team.name] = u'%(name)s (%(members)s)' % {
684+ u'name': team.team.name,
685+ u'members': human_join(m.name for m in team.team.members),
686+ }
687+
688+ scores = {}
689+ header = 0
690+ for tr in etree.getiterator(u'tr'):
691+ tds = [t for t in tr.getiterator(u'td')]
692+ if header == 0:
693+ header += 1
694+ continue
695+ if header == 1:
696+ problems = [t.text.strip() for t in tds[2:-2]]
697+ header += 1
698+ continue
699+ team = tds[1].text
700+ if team in teams.keys():
701+ team_scores = {}
702+ for i, problem in enumerate(problems):
703+ matches = re.search(r'(\d+):(\d+):(\d+)\s*\((\d+)\)', tds[i+2].text)
704+ penalty_time = (int(matches.group(1)) * 60 + \
705+ int(matches.group(2))) * 60 + int(matches.group(3))
706+ team_scores[problem] = ACMProblemScore(penalty_time > 0,
707+ int(matches.group(4)) + int(penalty_time > 0), penalty_time)
708+ scores[teams[team]] = ACMScore(team_scores)
709+ return (problems, scores)
710+
711+ @match(r'^uva\s+(remaining\s+time|time\s+remaining|countdown)(?:\s+(\d+))?$')
712+ def get_remaining_time(self, event, phrase, contest_id):
713+ if contest_id is None:
714+ if u'remaining' in phrase:
715+ contest_id = self._get_running_or_recent_contest().id
716+ else:
717+ contest_id = self._get_running_or_next_or_recent_contest().id
718+ url = u'http://uva.onlinejudge.org/index2.php?option=com_onlinejudge&' \
719+ u'Itemid=12&page=show_contest_statistics&contest=%s&fullscreen=1' \
720+ % contest_id
721+ etree = get_html_parse_tree(url, treetype='etree')
722+ time_elapsed = [h for h in etree.findall(
723+ u'body/table/tbody/tr/td/div/h1')][0].text.strip()
724+ time_remaining = [h for h in etree.findall(
725+ u'body/table/tbody/tr/td/div/h1')][2].text.strip()
726+ time_remaining = re.sub(r'\b(\d)\b', r'0\1', time_remaining)
727+ if '-' in time_elapsed:
728+ event.addresponse(u'%s until contest begins',
729+ time_elapsed.replace(u'-', u''))
730+ elif time_remaining == '00:00:00':
731+ event.addresponse(u'The contest is over')
732+ else:
733+ event.addresponse(u'%s time remaining', time_remaining)
734+
735+ @match(r'^uva\s+scoreboard(?:\s+(\d+))?$')
736+ def get_scoreboard(self, event, contest_id):
737+ if contest_id is None:
738+ contest_id = self._get_running_or_recent_contest().id
739+ problems, scores = self._get_scores(event, contest_id)
740+ response = []
741+
742+ for position, (team, score) in enumerate(sorted(
743+ scores.iteritems(), key=lambda (k,v): (v,k))):
744+ response.append(u'%(position)d. %(team)s: %(score)s' % {
745+ u'position': position+1,
746+ u'team': team,
747+ u'score': str(score),
748+ })
749+
750+ response.append(u'Full scoreboard: %s' % (self.scores_url % contest_id))
751+ event.addresponse(u'\n'.join(response), conflate=False)
752+
753+ @periodic(config_key='uva_monitor_period', initial_delay=5)
754+ def monitor(self, event):
755+ for source, channel in self.monitors:
756+ contest = self._get_running_contest()
757+ if contest is None:
758+ self.scores = {}
759+ continue
760+ problems, scores = self._get_scores(event, contest.id)
761+ response = []
762+ for team in scores.keys():
763+ if not self.scores.has_key(team):
764+ score = {}
765+ for id, problem in len(scores[team][0]):
766+ score[problem] = ACMProblemScore(False, 0, 0)
767+ self.scores[team] = ACMScore(score)
768+ for problem in sorted(problems):
769+ if scores[team].problem_scores[problem].correct and \
770+ not self.scores[team].problem_scores[problem].correct:
771+ prev_ranking = ranking(self.scores, team)
772+ new_ranking = ranking(scores, team)
773+ if prev_ranking == new_ranking:
774+ ranking_change = u'remaining in %s' % \
775+ human_ranking(new_ranking)
776+ else:
777+ ranking_change = u'moving from %(prev)s to %(new)s' % {
778+ u'prev': human_ranking(prev_ranking),
779+ u'new': human_ranking(new_ranking),
780+ }
781+ response.append(u'%(team)s has solved problem %(problem)s, '
782+ u'%(ranking_change)s with %(points)d problems in '
783+ u'%(time_penalty)s' % {
784+ u'team': team,
785+ u'problem': problem,
786+ u'ranking_change': ranking_change,
787+ u'points': scores[team].points(),
788+ u'time_penalty': time_penalty_str(scores[team].time_penalty()),
789+ })
790+ elif not scores[team].problem_scores[problem].correct and \
791+ self.scores[team].problem_scores[problem].correct:
792+ response.append(u"%(team)s's previously correct submission "
793+ u"for problem %(problem)s has been rejudged as incorrect!" % {
794+ u'team': team,
795+ u'problem': problem,
796+ })
797+ elif scores[team].problem_scores[problem].submissions > \
798+ self.scores[team].problem_scores[problem].submissions:
799+ num = scores[team].problem_scores[problem].submissions - \
800+ self.scores[team].problem_scores[problem].submissions
801+ total_wrong = scores[team].problem_scores[problem].submissions
802+ if num > 1:
803+ number = str(num)
804+ else:
805+ number = u'a'
806+ if total_wrong == num:
807+ overall = u'their first incorrect %s for the problem' % \
808+ plural(num, u'submission', u'submissions')
809+ else:
810+ overall = u'bringing them to %(total_wrong)d incorrect ' \
811+ '%(plural)s for the problem' % {
812+ u'total_wrong': total_wrong,
813+ u'plural': plural(total_wrong, u'submission', u'submissions'),
814+ }
815+ response.append(u'%(team)s has submitted %(number)s new incorrect '
816+ u'%(plural)s for problem %(problem)s, %(overall)s' % {
817+ u'team': team,
818+ u'problem': problem,
819+ u'number': number,
820+ u'plural': plural(num, u'submission', u'submissions'),
821+ u'overall': overall,
822+ })
823+ self.scores = scores
824+ if response:
825+ event.addresponse(u'\n'.join(response), conflate=False,
826+ source=source, target=channel)
827+
828+ @match(r'^(?:monitor\s+uva|uva\s+monitor)$')
829+ def start_monitor(self, event):
830+ contest = self._get_running_contest()
831+ if contest is None:
832+ self.scores = {}
833+ else:
834+ problems, self.scores = self._get_scores(event, contest.id)
835+ if (event.source, event.channel) in self.monitors:
836+ event.addresponse(u"Sorry, I'm already monitoring the scoreboard")
837+ return
838+ self.monitors.append((event.source, event.channel))
839+ event.addresponse(u"Okay, I'll monitor the scoreboard for you")
840+ if self.monitor.lock.acquire(0):
841+ self.monitor(event)
842+ self.monitor.lock.release()
843+
844+ @match(r'^(?:stop\s+monitoring\s+uva|uva\s+stop\s+monitor)$')
845+ def stop_monitor(self, event):
846+ if (event.source, event.channel) not in self.monitors:
847+ event.addresponse(u'I never even started monitoring the scoreboard')
848+ return
849+ self.monitors.remove((event.source, event.channel))
850+ event.addresponse(u'Okay, no longer monitoring the scoreboard')
851+
852
853 # vi: set et sta sw=4 ts=4:

Subscribers

People subscribed via source and target branches