├── .gitignore ├── .hgignore ├── .hgtags ├── .travis.yml ├── INSTALL ├── README.rst ├── setup.py ├── t ├── tests ├── __init__.py ├── test_commands.py └── test_payperiodtypes.py ├── timebook-bash-completion.sh └── timebook ├── __init__.py ├── autopost.py ├── chiliproject.py ├── cmdline.py ├── cmdutil.py ├── commands.py ├── config.py ├── db.py ├── dbutil.py ├── exceptions.py ├── migrations ├── 0001InitialMigration.py ├── 0002TicketMetadata.py ├── 0003AddHourAdjustments.py └── __init__.py ├── payperiodtypes.py └── payperiodutil.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | build/ 3 | dist/ 4 | *egg-info* 5 | -------------------------------------------------------------------------------- /.hgignore: -------------------------------------------------------------------------------- 1 | build/.* 2 | dist/.* 3 | .*egg-info.* 4 | .*pyc 5 | timebook/logs/* 6 | .*\.wsgi 7 | .*\.git.* 8 | -------------------------------------------------------------------------------- /.hgtags: -------------------------------------------------------------------------------- 1 | 316658b472e44cf54aa66c2762fbb2db29b0ca68 1.0 2 | bb535ee517cc2c4e1685a9fea350d0d8114daddf 1.0.1 3 | d4597e96b0a808f2c14164f639a5b8593e1f14f9 1.0.2 4 | 8a211171f9abfc751b34d85a804fb0e7c82c6028 1.1 5 | 3249ee1a7552751ee680eac2fe1809f508027300 1.1.1 6 | 5356943817457b30e98303c4329d84c2e9fd0564 1.2 7 | 40797718845bf1775f2bc943997778722fe16014 2.0.2 8 | 4a3f482585887ba8414f2a5f0181c4fbbb9bf67a 2.0.3 9 | d2edb82220e8069a53f9a417074a00799e990621 3.0 10 | 1570a9396257b6be57150ba0d11b7c7089031ec1 3.5 11 | 1a30e52eba4284c1dec5d2f19d657aeb8605d3a0 3.5.1 12 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - "2.6" 4 | - "2.7" 5 | install: 6 | - pip install -q -e . --use-mirrors 7 | script: 8 | - python setup.py test 9 | -------------------------------------------------------------------------------- /INSTALL: -------------------------------------------------------------------------------- 1 | Installation 2 | ============ 3 | 4 | To install timebook, run the following command from the mercurial 5 | repository:: 6 | 7 | python setup.py install 8 | 9 | This will install timebook to the default location on your system. To 10 | install to a different directory, supply setup.py with the ``--prefix`` 11 | option. For details, refer to ``python setup.py install --help`` and the 12 | python setuptools documentation. 13 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | .. -*- restructuredtext -*- 2 | 3 | **Warning: This library is no longer maintained.** 4 | 5 | .. image:: https://travis-ci.org/coddingtonbear/timebook.png?branch=master 6 | 7 | Timebook 8 | ======== 9 | 10 | Timebook is a small utility which aims to be a low-overhead way of 11 | tracking what you spend time on. It can be used to prepare annotated 12 | time logs of work for presentation to a client, or simply track how you 13 | spend your free time. Timebook is implemented as a python script which 14 | maintains its state in a sqlite3 database. 15 | 16 | Concepts 17 | ~~~~~~~~ 18 | 19 | Timebook maintains a list of *timesheets* -- distinct lists of timed 20 | *periods*. Each period has a start and end time, with the exception of the 21 | most recent period, which may have no end time set. This indicates that 22 | this period is still running. Timesheets containing such periods are 23 | considered *active*. It is possible to have multiple timesheets active 24 | simultaneously, though a single time sheet may only have one period 25 | running at once. 26 | 27 | Interactions with timebook are performed through the ``t`` command on 28 | the command line. ``t`` is followed by one of timebook's subcommands. 29 | Often used subcommands include ``in``, ``out``, ``switch``, ``now``, 30 | ``list`` and ``display``. Commands may be abbreviated as long as they 31 | are unambiguous: thus ``t switch foo`` and ``t s foo`` are identical. 32 | With the default command set, no two commands share the first same 33 | letter, thus it is only necessary to type the first letter of a command. 34 | Likewise, commands which display timesheets accept abbreviated timesheet 35 | names. ``t display f`` is thus equivalent to ``t display foo`` if 36 | ``foo`` is the only timesheet which begins with "f". Note that this does 37 | not apply to ``t switch``, since this command also creates timesheets. 38 | (Using the earlier example, if ``t switch f`` is entered, it would thus 39 | be ambiguous whether a new timesheet ``f`` or switching to the existing 40 | timesheet ``foo`` was desired). 41 | 42 | Usage 43 | ~~~~~ 44 | 45 | The basic usage is as follows:: 46 | 47 | $ t in 'document timebook' 48 | $ t change 'doing something else' 49 | $ t out 50 | 51 | The first command, ``t in 'document timebook'`` creates a new period in 52 | the current timesheet, and annotates it with the description "document 53 | timebook". The second, ``t change 'doing something else'`` ends the first period 54 | you created a moment ago, and starts a new period, annotating it with the 55 | description 'doing something else'. Finally, ``t out`` records the current 56 | time as the end time for the most recent period in the ``writing`` 57 | timesheet. 58 | 59 | To display the current timesheet, invoke the ``t display`` command:: 60 | 61 | $ t display 62 | Timesheet writing: 63 | Day Start End Duration Notes 64 | Mar 14, 2009 19:53:30 - 20:06:15 0:12:45 document timebook 65 | 20:07:02 - 0:00:01 write home about timebook 66 | 0:12:46 67 | Total 0:12:46 68 | 69 | Each period in the timesheet is listed on a row. If the timesheet is 70 | active, the final period in the timesheet will have no end time. After 71 | each day, the total time tracked in the timesheet for that day is 72 | listed. Note that this is computed by summing the durations of the 73 | periods beginning in the day. In the last row, the total time tracked in 74 | the timesheet is shown. 75 | 76 | Parthenon-Related Usage 77 | ~~~~~~~~~~~~~~~~~~~~~~~ 78 | 79 | Annotating work-related projects can be somewhat more complicated due to having 80 | specific projects associated with billable or non-billable tickets, but 81 | timebook will help make this reasonably easy for you by allowing you to specify, 82 | in addition to a description, a ticket number that will be used when posting your 83 | timesheet (you can change 'in' to 'change' should you be switching tasks instead 84 | of starting a new one):: 85 | 86 | $ t in --ticket=1038 "Working on my falafel recipe" 87 | 88 | The above command will enter 'Working on my falafel recipe' into your timesheet, 89 | set the entry's ticket number to '1038' and mark the task as billable (the default). 90 | But, what if you want your ticket to be marked as non-billable? :: 91 | 92 | $ t in --ticket=1038 --non-billable "Working on my falafel recipe" 93 | 94 | Additionally, you can modify previous entries' ticket number and billable status 95 | (as well as any custom attributes) by using the ``alter`` command, optionally 96 | providing the ID number of an entry of which you'd like to change the properties. :: 97 | 98 | $ t alter --id=208 --ticket=2408 99 | 100 | At the end of the day, you can post your hours to our timesheet automatically 101 | by running:: 102 | 103 | $ t post 104 | 105 | If you do not have your credentials saved in the configuration, you will 106 | be asked for your username and password, statistics will be gathered (if 107 | possible) for the entries you are posting, and your entries will be posted 108 | to your timesheet online. 109 | 110 | Web Interface 111 | ~~~~~~~~~~~~~ 112 | 113 | A web interface for viewing timebook information is available in the project 114 | ``timebook_web``; head over to http://bitbucket.org/coddingtonbear/timebook_web/ 115 | for details. 116 | 117 | Configuration 118 | ~~~~~~~~~~~~~ 119 | 120 | A configuration file lives in ``~/.config/timebook/timebook.ini`` that you can 121 | use to configure various options in the timebook application including setting 122 | your ChiliProject username and password. 123 | 124 | If you'd like to not be asked for your username and password when you're posting 125 | a timesheet and/or allow the web interface to gather information from ChiliProject 126 | directly, you can enter your username and password inside the above file in 127 | a format like:: 128 | 129 | [auth] 130 | username = MY USERNAME 131 | password = MY PASSWORD 132 | 133 | Additionally, you can set sheet-specific reporting urls, hooks, etc by setting 134 | a configuration section using the name of the sheet for which you would like 135 | a pre, post, or reporting hook to be executed, and the name of the URL or 136 | application you would like executed like:: 137 | 138 | [default] 139 | post_hook = /path/to/some/application 140 | pre_hook = /path/to/some/other/application 141 | reporting_url = http://www.somedomain.com/reporting/ 142 | 143 | [some_other_client] 144 | post_out_hook = /path/to/application 145 | autocontinue = 146 | 147 | Hooks 148 | ----- 149 | 150 | Hooks can be assigned a per-timesheet and per-timesheet-per-command basis by adding 151 | entries to your timesheet configuration like:: 152 | 153 | [timesheet] 154 | post_hook = /path/to/some/post_hook/application/ 155 | pre_out_hook = /path/to/some/pre_out_hook/application/ 156 | 157 | In the above example, the command ``/path/to/some/post_hook/application/`` will 158 | be executed after every command; if the command exits with a non-zero status, 159 | an error will be displayed (but the entry will still be created successfully). 160 | 161 | Additionally, the command ``/path/to/some/pre_out_hook/application/`` will be 162 | executed before every execution of the ``out`` command (which is executed when 163 | one runs the ``t out`` command as well as the ``t change`` command). Should the 164 | hooked application execute with a non-zero status, an error will be displayed and 165 | the entry *will not* be created successfully. 166 | 167 | Autocontinuation 168 | ---------------- 169 | 170 | Should you be working on a project with very-fine-grained tasks, you may consider 171 | enabling autocontinue by adding an entry to your timesheet configuration like:: 172 | 173 | [timesheet] 174 | autocontinue = 175 | 176 | Autocontinuation will cause task details that you do not explicitly specify 177 | to be preserved from the previous timesheet entry to your current timesheet 178 | entry when you execute ``t change``. For example:: 179 | 180 | t in --ticket=12308 "Helping Brian" 181 | // Entry is annotated with ticket# 12308 and a description of "Helping Brian" 182 | t change "Troubleshooting with Joseph" 183 | // Entry is *still* annotated with ticket# 12308 and a description of "Troubleshooting with Joesph" 184 | 185 | Custom Metadata 186 | --------------- 187 | 188 | You might have a peculiar use for storing some specific bit of metadata about 189 | individual ticket entries. You can use custom metadata attributes to provide 190 | this functionality. 191 | 192 | To use custom metadata attributes, create a configuration section named 193 | ``custom_ticket_meta`` with the keys and values named after the name of the 194 | attribute and its help text, respectively:: 195 | 196 | [custom_ticket_meta] 197 | with=Who are you working with right now? 198 | category=What category is the work you're working on? 199 | 200 | This will add two new parameters that are settable and modifiable during your 201 | ``t in``, ``t change`` and ``t alter`` commands just like built-in attributes 202 | like an entry's associated ticket number and billable status. 203 | 204 | Command Aliases 205 | --------------- 206 | 207 | You will quickly notice that there are rather a lot of commands and that the 208 | connection between the command name and its action may be entirely unclear 209 | to you; in order to allow one to use the system in a way that suits their cognitive 210 | processes best, you are able to specify aliases for any command. 211 | 212 | For example, if you would prefer to use the command ``to`` instead of ``change`` 213 | when changing tasks , you can create aliases in an 214 | ``aliases`` section in your Timebook configuration. :: 215 | 216 | [aliases] 217 | to=change 218 | 219 | You can also override built-in commands; so if you rarely use the built-in ``switch`` 220 | command and would rather have it behave as ``change`` already does, you can, of course, 221 | do that, too. 222 | 223 | 224 | Commands 225 | ~~~~~~~~ 226 | 227 | **alter** 228 | Inserts a note associated with the currently active period in the 229 | timesheet. 230 | 231 | *Also accepts custom ticket metadata parameters.* 232 | 233 | usage: ``t alter [--billable] [--non-billable] [--ticket=TICKETNUMBER] [--id=ID] NOTES...`` 234 | 235 | hooks: ``post_alter_hook``, ``pre_alter_hook`` 236 | 237 | aliases: *write* 238 | 239 | **backend** 240 | Run an interactive database session on the timebook database. Requires 241 | the sqlite3 command. 242 | 243 | usage: ``t backend`` 244 | 245 | hooks: ``post_backend_hook``, ``pre_backend_hook`` 246 | 247 | aliases: *shell* 248 | 249 | **change** 250 | Stop the timer for the current timesheet, and re-start the timer for the 251 | current timesheet with a new description. Notes may be specified for this 252 | period. This is roughly equivalent to ``t out; t in NOTES``, excepting that 253 | any metadata set for the previous timesheet entry will be preserved for the 254 | new timesheet entry. 255 | 256 | *Also accepts custom ticket metadata parameters.* 257 | 258 | usage: ``t change [--billable] [--non-billable] [--ticket=TICKETNUMBER] [NOTES...]`` 259 | 260 | hooks: ``post_change_hook``, ``pre_change_hook``, ``pre_in__hook``, ``post_in__hook``, ``pre_out_hook``, ``post_out_hook`` 261 | 262 | **details** 263 | Displays details regarding tickets assigned to a specified ticket number. 264 | 265 | Information displayed includes the project name and ticket title, as well 266 | as the number of hours attributed to the specified ticket and the billable 267 | percentage. 268 | 269 | usage: ``t details TICKET_NUMBER`` 270 | 271 | hooks: ``pre_details_hook``, ``post_details_hook`` 272 | 273 | **display** 274 | Display a given timesheet. If no timesheet is specified, show the 275 | current timesheet. 276 | 277 | Additionally allows one to display the ID#s for individual timesheet 278 | entries (for making modifications). 279 | 280 | *By default, shows only the last seven days of activity.* 281 | 282 | usage: ``t display [--show-ids] [--start=YYYY-MM-DD] [--end=YYYY-MM-DD] [TIMESHEET]`` 283 | 284 | hooks: ``pre_display_hook``, ``post_display_hook`` 285 | 286 | aliases: *show* 287 | 288 | **hours** 289 | Calculates your timesheet's current balance for the current pay period 290 | given a 40-hour work week. 291 | 292 | Uses entries in additional tables named *unpaid*, *vacation*, and *holiday* 293 | to calculate whether a specific day counts as one during which you are 294 | expecting to reach eight hours. 295 | 296 | usage: ``t hours`` 297 | 298 | hooks: ``pre_hours_hook``, ``post_hours_hook`` 299 | 300 | aliases: *payperiod*, *pay*, *period*, *offset* 301 | 302 | **in** 303 | Start the timer for the current timesheet. Must be called before out. 304 | Notes may be specified for this period. This is exactly equivalent to 305 | ``t in; t alter NOTES`` 306 | 307 | *Also accepts custom ticket metadata parameters.* 308 | 309 | usage: ``t in [--billable] [--non-billable] [--ticket=TICKETNUMBER] [--switch TIMESHEET] [NOTES...]`` 310 | 311 | hooks: ``pre_in__hook``, ``post_in__hook`` 312 | 313 | aliases: *start* 314 | 315 | **insert** 316 | Insert a new entry into the current timesheet. Times must be in the 317 | YYYY-MM-DD HH:MM format, and all parameters should be quoted. 318 | 319 | usage: ``t insert START END NOTE`` 320 | 321 | hooks: ``pre_insert_hook``, ``post_insert_hook`` 322 | 323 | **kill** 324 | Delete a timesheet. If no timesheet is specified, delete the current 325 | timesheet and switch to the default timesheet. 326 | 327 | usage: ``t kill [TIMESHEET]`` 328 | 329 | hooks: ``pre_kill_hook``, ``post_kill_hook`` 330 | 331 | aliases: *delete* 332 | 333 | **list** 334 | List the available timesheets. 335 | 336 | usage: ``t list [--summary]`` 337 | 338 | hooks: ``pre_list_hook``, ``post_list_hook`` 339 | 340 | aliases: *ls* 341 | 342 | 343 | **modify** 344 | Provides a facility for one to modify a previously-entered timesheet entry. 345 | 346 | Requires the ID# of the timesheet entry; please see the command 347 | named *display* above. 348 | 349 | usage ``t modify ID`` 350 | 351 | hooks: ``pre_modify_hook``, ``post_modify_hook`` 352 | 353 | **now** 354 | Print the current sheet, whether it's active, and if so, how long it 355 | has been active and what notes are associated with the current period. 356 | 357 | If a specific timesheet is given, display the same information for 358 | that timesheet instead. 359 | 360 | usage: ``t now [--simple] [TIMESHEET]`` 361 | 362 | hooks: ``pre_now_hook``, ``post_now_hook`` 363 | 364 | aliases: *info* 365 | 366 | **out** 367 | Stop the timer for the current timesheet. Must be called after in. 368 | 369 | usage: ``t out [--verbose] [TIMESHEET]`` 370 | 371 | hooks: ``pre_out_hook``, ``post_out_hook`` 372 | 373 | aliases: *stop* 374 | 375 | **post** 376 | Posts your current timesheet to our internal hours tracking system. 377 | 378 | The application will not require your input to post hours if you have stored 379 | your credentials in your configuration, but if you have not, your username 380 | and password will be requested. 381 | 382 | usage ``t post [--date=YYYY-MM-DD]`` 383 | 384 | hooks: ``pre_post_hook``, ``post_post_hook`` 385 | 386 | **running** 387 | Print all active sheets and any messages associated with them. 388 | 389 | usage: ``t running`` 390 | 391 | hooks: ``pre_running_hook``, ``post_running_hook`` 392 | 393 | aliases: *active* 394 | 395 | **stats** 396 | Print out billable hours and project time allocation details for the past 397 | seven days. 398 | 399 | Optionally you can specify the range of time for which you'd like statistics 400 | calculated. 401 | 402 | usage ``t stats [--start=YYYY-MM-DD] [--end=YYYY-MM-DD]`` 403 | 404 | hooks: ``pre_stats_hook``, ``post_stats_hook`` 405 | 406 | **switch** 407 | Switch to a new timesheet. this causes all future operation (except 408 | switch) to operate on that timesheet. The default timesheet is called 409 | "default". 410 | 411 | usage: ``t switch TIMESHEET`` 412 | 413 | hooks: ``pre_switch_hook``, ``post_switch_hook`` 414 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import multiprocessing 2 | 3 | from setuptools import setup 4 | 5 | from timebook import get_version 6 | 7 | setup( 8 | name='timebook', 9 | version=get_version(), 10 | url='http://bitbucket.org/latestrevision/timebook/', 11 | description='track what you spend time on', 12 | author='Trevor Caira, Adam Coddington', 13 | author_email='me@adamcoddington.net', 14 | classifiers=[ 15 | 'Intended Audience :: Developers', 16 | 'License :: OSI Approved :: MIT License', 17 | 'Operating System :: OS Independent', 18 | 'Programming Language :: Python', 19 | 'Topic :: Utilities', 20 | ], 21 | packages=['timebook', 'timebook.migrations',], 22 | entry_points={'console_scripts': [ 23 | 't = timebook.cmdline:run_from_cmdline']}, 24 | install_requires=[ 25 | 'python-dateutil', 26 | ], 27 | test_suite='nose.collector', 28 | tests_require=[ 29 | 'ddt>=0.4', 30 | 'mock', 31 | 'nose', 32 | ] 33 | ) 34 | -------------------------------------------------------------------------------- /t: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | 3 | from timebook.cmdline import run_from_cmdline 4 | run_from_cmdline() 5 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/coddingtonbear/timebook/fa53316b872d4d491ac00a176a0bcab3f93e3414/tests/__init__.py -------------------------------------------------------------------------------- /tests/test_commands.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import StringIO 3 | import sys 4 | import time 5 | import unittest 6 | 7 | import mock 8 | from nose.plugins.skip import SkipTest 9 | 10 | import timebook.db 11 | import timebook.commands as cmds 12 | 13 | class TestCommandFunctions(unittest.TestCase): 14 | def setUp(self): 15 | raise SkipTest( 16 | 'Temporarily broken' 17 | ) 18 | config_mock = mock.Mock() 19 | config_mock.get.return_value = False 20 | self.default_sheet = 'default' 21 | self.db_mock = timebook.db.Database( 22 | ':memory:', 23 | config_mock 24 | ) 25 | self.arbitrary_args = [] 26 | 27 | cmds.pre_hook = mock.Mock() 28 | cmds.post_hook = mock.Mock() 29 | 30 | self.now = int(time.time()) 31 | cmds.cmdutil.parse_date_time_or_now = mock.Mock( 32 | return_value = self.now 33 | ) 34 | 35 | def adjust_output_rows_for_time(self, rows): 36 | processed = [] 37 | for row in rows: 38 | processed_row = [] 39 | for column in row: 40 | column = column.format( 41 | time = datetime.datetime.fromtimestamp( 42 | self.now 43 | ).strftime('%H:%M:%S'), 44 | date = datetime.datetime.fromtimestamp( 45 | self.now 46 | ).strftime('%b %d, %Y'), 47 | ) 48 | processed_row.append(column) 49 | processed.append(processed_row) 50 | return processed 51 | 52 | def capture_output(self, cmd, args = None, kwargs = None): 53 | if not args: 54 | args = [] 55 | if not kwargs: 56 | kwargs = {} 57 | real_out = sys.stdout 58 | sys.stdout = StringIO.StringIO() 59 | cmd.__call__(*args, **kwargs) 60 | output = sys.stdout.getvalue().strip() 61 | sys.stdout = real_out 62 | return output 63 | 64 | def get_entry_rows(self): 65 | self.db_mock.execute(''' 66 | select * from entry; 67 | ''') 68 | return self.db_mock.fetchall() 69 | 70 | def tabularize_output(self, output): 71 | output_rows = [] 72 | 73 | rows = output.split('\n') 74 | for row in rows: 75 | this_row = [] 76 | cols = row.split('\t') 77 | for col in cols: 78 | this_row.append( 79 | col.strip() 80 | ) 81 | output_rows.append( 82 | this_row 83 | ) 84 | return output_rows 85 | 86 | def test_backend(self): 87 | arbitrary_path = '/path/to/db/' 88 | sqlite3_binary = 'sqlite3' 89 | self.db_mock.path = arbitrary_path 90 | 91 | cmds.subprocess.call = mock.MagicMock() 92 | cmds.backend(self.db_mock, self.arbitrary_args) 93 | 94 | cmds.subprocess.call.assert_called_with( 95 | ( 96 | sqlite3_binary, 97 | arbitrary_path, 98 | ) 99 | ) 100 | 101 | def test_post(self): 102 | arbitrary_login_url = "http://some.url/" 103 | arbitrary_timesheet_url = "http://some.other.url/" 104 | arbitrary_db_path = "/path/to/some/db" 105 | arbitrary_config_file = "/path/to/some/file" 106 | 107 | cmds.LOGIN_URL = arbitrary_login_url 108 | cmds.TIMESHEET_URL = arbitrary_timesheet_url 109 | cmds.TIMESHEET_DB = arbitrary_db_path 110 | cmds.CONFIG_FILE = arbitrary_config_file 111 | 112 | cmds.TimesheetPoster= mock.MagicMock() 113 | 114 | cmds.post(self.db_mock, self.arbitrary_args) 115 | 116 | cmds.TimesheetPoster.assert_called_with( 117 | self.db_mock, 118 | datetime.datetime.fromtimestamp(self.now).date(), 119 | ) 120 | 121 | self.assertTrue( 122 | mock.call().__enter__().main() 123 | in cmds.TimesheetPoster.mock_calls 124 | ) 125 | 126 | def test_in(self): 127 | arbitrary_description = "Some Description" 128 | expected_row = ( 129 | 1, 130 | self.default_sheet, 131 | self.now, 132 | None, 133 | arbitrary_description, 134 | None 135 | ) 136 | args = [arbitrary_description] 137 | 138 | cmds.in_(self.db_mock, args) 139 | 140 | rows = self.get_entry_rows() 141 | 142 | self.assertEqual( 143 | rows, 144 | [expected_row, ] 145 | ) 146 | 147 | def test_list_when_no_sheets(self): 148 | output = self.capture_output( 149 | cmds.list, 150 | [self.db_mock, self.arbitrary_args, ] 151 | ) 152 | self.assertEqual( 153 | output, 154 | "(no sheets)" 155 | ) 156 | 157 | def test_list_when_has_sheets(self): 158 | expected_rows = [ 159 | [u'Timesheet Running Today Total time'], 160 | [u'*default -- 0:00:00 0:00:00'] 161 | ] 162 | cmds.in_(self.db_mock, ['arbitrary description']) 163 | output = self.capture_output( 164 | cmds.list, 165 | [self.db_mock, self.arbitrary_args, ] 166 | ) 167 | output = self.tabularize_output(output) 168 | self.assertEqual( 169 | output, 170 | expected_rows 171 | ) 172 | 173 | def test_switch(self): 174 | args = [ 175 | 'Something New' 176 | ] 177 | cmds.switch(self.db_mock, args) 178 | 179 | current_sheet = cmds.dbutil.get_current_sheet(self.db_mock) 180 | 181 | self.assertEqual( 182 | current_sheet, 183 | args[0] 184 | ) 185 | 186 | def test_out(self): 187 | expected_rows = [ 188 | ( 189 | 1, 190 | u'default', 191 | self.now, 192 | self.now, 193 | None, 194 | None 195 | ) 196 | ] 197 | 198 | args = [] 199 | cmds.in_(self.db_mock, args) 200 | cmds.out(self.db_mock, args) 201 | 202 | rows = self.get_entry_rows() 203 | 204 | self.assertEqual( 205 | rows, 206 | expected_rows 207 | ) 208 | 209 | def test_out_single_sheet(self): 210 | expected_rows = [ 211 | ( 212 | 1, 213 | u'default', 214 | self.now, 215 | self.now, 216 | None, 217 | None 218 | ), 219 | ( 220 | 2, 221 | u'another_sheet', 222 | self.now, 223 | None, 224 | None, 225 | None 226 | ) 227 | ] 228 | 229 | args = [] 230 | cmds.in_(self.db_mock, args) 231 | cmds.switch(self.db_mock, ['another_sheet', ]) 232 | cmds.in_(self.db_mock, args) 233 | cmds.switch(self.db_mock, ['default', ]) 234 | cmds.out(self.db_mock, args) 235 | 236 | rows = self.get_entry_rows() 237 | 238 | self.assertEqual( 239 | rows, 240 | expected_rows 241 | ) 242 | 243 | def test_out_all_sheets(self): 244 | expected_rows = [ 245 | ( 246 | 1, 247 | u'default', 248 | self.now, 249 | self.now, 250 | None, 251 | None 252 | ), 253 | ( 254 | 2, 255 | u'another_sheet', 256 | self.now, 257 | self.now, 258 | None, 259 | None 260 | ) 261 | ] 262 | 263 | args = [] 264 | cmds.in_(self.db_mock, args) 265 | cmds.switch(self.db_mock, ['another_sheet', ]) 266 | cmds.in_(self.db_mock, args) 267 | cmds.switch(self.db_mock, ['default', ]) 268 | cmds.out(self.db_mock, ['--all']) 269 | 270 | rows = self.get_entry_rows() 271 | 272 | self.assertEqual( 273 | rows, 274 | expected_rows 275 | ) 276 | 277 | def test_alter(self): 278 | original_name = 'current task' 279 | later_name = 'something else' 280 | expected_rows = [ 281 | ( 282 | 1, 283 | u'default', 284 | self.now, 285 | None, 286 | later_name, 287 | None 288 | ), 289 | ] 290 | cmds.in_(self.db_mock, [original_name, ]) 291 | cmds.alter(self.db_mock, [later_name, ]) 292 | 293 | rows = self.get_entry_rows() 294 | 295 | self.assertEqual( 296 | rows, 297 | expected_rows 298 | ) 299 | 300 | def test_running(self): 301 | expected_rows = [ 302 | [u'Timesheet Description'], 303 | [u'default some task'] 304 | ] 305 | cmds.in_(self.db_mock, ['some task', ]) 306 | output = self.tabularize_output( 307 | self.capture_output(cmds.running, [ 308 | self.db_mock, 309 | self.arbitrary_args, 310 | ] 311 | ) 312 | ) 313 | self.assertEqual( 314 | output, 315 | expected_rows, 316 | ) 317 | 318 | def test_display(self): 319 | expected_rows = self.adjust_output_rows_for_time([ 320 | [u'Day Start End Duration Notes Billable'], 321 | [u'{date}' + ' {time} - {time} ' +' 00:00:00 some task yes'], 322 | [u'{time} - ' + ' 00:00:00 some other task yes'], 323 | [u'00:00:00'], 324 | [u'Total 00:00:00'] 325 | ]) 326 | cmds.in_(self.db_mock, ['some task', ]) 327 | cmds.out(self.db_mock, []) 328 | cmds.in_(self.db_mock, ['some other task', ]) 329 | 330 | output = self.tabularize_output( 331 | self.capture_output( 332 | cmds.display, 333 | [ 334 | self.db_mock, 335 | self.arbitrary_args, 336 | ] 337 | ) 338 | ) 339 | 340 | self.assertEqual( 341 | output, 342 | expected_rows, 343 | ) 344 | 345 | if __name__ == '__main__': 346 | unittest.main() 347 | -------------------------------------------------------------------------------- /tests/test_payperiodtypes.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | import unittest 3 | 4 | from ddt import ddt, data 5 | 6 | from timebook import payperiodtypes 7 | 8 | 9 | @ddt 10 | class TestPayPeriodCalculations(unittest.TestCase): 11 | @data( 12 | ( 13 | datetime(2013, 5, 1), 14 | datetime(2013, 4, 1), 15 | datetime(2013, 5, 2), 16 | ), 17 | ( 18 | datetime(2013, 4, 28), 19 | datetime(2013, 3, 29), 20 | datetime(2013, 4, 29), 21 | ), 22 | ) 23 | def test_rolling_30_day_window_calculations(self, value): 24 | date, begin_period, end_period = value 25 | instance = payperiodtypes.Rolling30DayWindow(date) 26 | self.assertEquals( 27 | instance.begin_period, 28 | begin_period 29 | ) 30 | self.assertEquals( 31 | instance.end_period, 32 | end_period 33 | ) 34 | 35 | @data( 36 | ( 37 | datetime(2013, 5, 1), 38 | datetime(2013, 4, 20), 39 | datetime(2013, 5, 25), 40 | ), 41 | ( 42 | datetime(2013, 4, 18), 43 | datetime(2013, 3, 23), 44 | datetime(2013, 4, 20), 45 | ), 46 | ) 47 | def test_monthly_on_second_to_last_friday_calculations(self, value): 48 | date, begin_period, end_period = value 49 | instance = payperiodtypes.MonthlyOnSecondToLastFriday(date) 50 | self.assertEquals( 51 | instance.begin_period, 52 | begin_period 53 | ) 54 | self.assertEquals( 55 | instance.end_period, 56 | end_period 57 | ) 58 | -------------------------------------------------------------------------------- /timebook-bash-completion.sh: -------------------------------------------------------------------------------- 1 | # timebook(1) completion -*- shell-script -*- 2 | # Copyright 2016 Raphaël Droz 3 | # License: GNU GPL v3 or later 4 | 5 | # Install: Put this file inside under the global or local (~/.bash_completion.d/completions) 6 | # bash completions path and name this file "t". 7 | 8 | _t() 9 | { 10 | local cur prev words cword cmd 11 | _init_completion || return 12 | 13 | case "$prev" in 14 | alter|running) 15 | return 0 16 | ;; 17 | 18 | list|out) # no other argument than options 19 | COMPREPLY=( $( compgen -W "$(t $prev --help|_parse_help -)" -- "$cur" ) ) 20 | return 0 21 | ;; 22 | 23 | switch|display|now|in) 24 | # all takes a timesheet or options as arguments 25 | COMPREPLY=( $( compgen -W "$(t list --simple)" -- "$cur" ) ) 26 | COMPREPLY+=( $( compgen -W "$(t $prev --help|_parse_help -)" -- "$cur" ) ) 27 | return 0 28 | ;; 29 | 30 | kill) 31 | COMPREPLY=( $( compgen -W "$(t list --simple)" -- "$cur" ) ) 32 | return 0 33 | ;; 34 | 35 | --config|-C|--timebook|-b) 36 | _filedir 37 | return 0 38 | ;; 39 | 40 | # display 41 | -f|--format) 42 | COMPREPLY=( $( compgen -W "plain csv" -- "$cur" ) ) 43 | return 0 44 | ;; 45 | --start|-s|--end|-e) 46 | return 0 # dates 47 | ;; 48 | 49 | # in 50 | --switch) # -s) TODO: conflict with display -s 51 | COMPREPLY=( $( compgen -W "$(t list --simple)" -- "$cur" ) ) 52 | return 0 53 | ;; 54 | esac 55 | 56 | if [[ "$cur" == -* ]]; then 57 | cmd="${words[1]}" 58 | if [[ $cmd =~ ^alter|running|list|out|switch|display|now|kill$ ]]; then 59 | COMPREPLY=( $( compgen -W '$(t $prev --help|_parse_help -)' -- "$cur" ) ) 60 | elif [[ -z "$cmd" || $cmd == $1 || $cmd =~ ^-- ]]; then 61 | COMPREPLY=( $( compgen -W '$(_parse_help $1 --help)' -- "$cur" ) ) 62 | else : # dunno 63 | fi 64 | 65 | # completion --xxx= don't add extra-space 66 | if (( "${#COMPREPLY[*]}" == 1 )) && [[ "${COMPREPLY[0]}" =~ =$ ]]; then 67 | compopt -o nospace; 68 | fi 69 | return 0 70 | fi 71 | 72 | # main completion 73 | COMPREPLY=( $( compgen -W 'alter backend display in kill list nonw out running switch' -- "$cur" ) ) 74 | COMPREPLY+=( $( compgen -W "$(_parse_help $1)" -- "$cur" ) ) 75 | 76 | 77 | return 0 78 | } && 79 | complete -F _t t 80 | 81 | # Local variables: 82 | # mode: shell-script 83 | # sh-basic-offset: 4 84 | # sh-indent-comment: t 85 | # indent-tabs-mode: nil 86 | # End: 87 | # ex: ts=4 sw=4 et filetype=sh 88 | -------------------------------------------------------------------------------- /timebook/__init__.py: -------------------------------------------------------------------------------- 1 | # __init__.py 2 | # 3 | # Copyright (c) 2008-2009 Trevor Caira, 2011-2012 Adam Coddington 4 | # 5 | # Permission is hereby granted, free of charge, to any person obtaining 6 | # a copy of this software and associated documentation files (the 7 | # "Software"), to deal in the Software without restriction, including 8 | # without limitation the rights to use, copy, modify, merge, publish, 9 | # distribute, sublicense, and/or sell copies of the Software, and to 10 | # permit persons to whom the Software is furnished to do so, subject to 11 | # the following conditions: 12 | # 13 | # The above copyright notice and this permission notice shall be 14 | # included in all copies or substantial portions of the Software. 15 | # 16 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | # IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 20 | # CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 21 | # TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 22 | # SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | import logging 25 | import os.path 26 | import sys 27 | 28 | __author__ = 'Adam Coddington , Trevor Caira ' 29 | __version__ = (3, 7, 5) 30 | 31 | def get_version(): 32 | return '.'.join(str(bit) for bit in __version__) 33 | 34 | logger = logging.getLogger('timebook') 35 | logger.setLevel(logging.DEBUG) 36 | 37 | handler = logging.StreamHandler(sys.stderr) 38 | handler.setLevel(logging.INFO) 39 | logger.addHandler(handler) 40 | 41 | CONFIG_DIR = os.path.expanduser('~/.config/timebook') 42 | CONFIG_FILE = os.path.join(CONFIG_DIR, "timebook.ini") 43 | TIMESHEET_DB = os.path.join(CONFIG_DIR, "sheets.db") 44 | -------------------------------------------------------------------------------- /timebook/autopost.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2011-2012 Adam Coddington 2 | # 3 | # Permission is hereby granted, free of charge, to any person obtaining 4 | # a copy of this software and associated documentation files (the 5 | # "Software"), to deal in the Software without restriction, including 6 | # without limitation the rights to use, copy, modify, merge, publish, 7 | # distribute, sublicense, and/or sell copies of the Software, and to 8 | # permit persons to whom the Software is furnished to do so, subject to 9 | # the following conditions: 10 | # 11 | # The above copyright notice and this permission notice shall be 12 | # included in all copies or substantial portions of the Software. 13 | # 14 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 17 | # IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 18 | # CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 19 | # TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 20 | # SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | import getpass 22 | import ConfigParser 23 | import urllib 24 | import urllib2 25 | 26 | from timebook.chiliproject import ChiliprojectConnector 27 | from timebook.dbutil import TimesheetRow, get_entry_meta 28 | from timebook.exceptions import AuthenticationError 29 | 30 | 31 | class TimesheetPoster(object): 32 | _config_section = 'timesheet_poster' 33 | 34 | def __init__(self, db, date): 35 | self.timesheet_url = db.config.get_with_default( 36 | self._config_section, 37 | 'timesheet_url', 38 | 'http://www.parthenonsoftware.com/timesheet/timesheet.php' 39 | ) 40 | self.login_url = db.config.get_with_default( 41 | self._config_section, 42 | 'login_url', 43 | 'http://www.parthenonsoftware.com/timesheet/index.php' 44 | ) 45 | self.date = date 46 | self.db = db 47 | 48 | def get_config(self, section, option): 49 | try: 50 | return self.db.config.get(section, option) 51 | except (ConfigParser.NoSectionError, ConfigParser.NoOptionError) as e: 52 | if(option.upper().find("pass") or option[0:1] == "_"): 53 | return getpass.getpass("%s: " % option.capitalize()) 54 | else: 55 | return raw_input("%s: " % option.capitalize()) 56 | 57 | def main(self): 58 | print "Posting hours for %s" % self.date 59 | self.username = self.get_config('auth', 'username') 60 | self.password = self.get_config('auth', 'password') 61 | 62 | entries = self.get_entries(self.date) 63 | for entry in entries: 64 | print entry 65 | opener = self.login(self.login_url, self.username, self.password) 66 | result = self.post_entries( 67 | opener, 68 | self.timesheet_url, 69 | self.date, 70 | entries 71 | ) 72 | return result 73 | 74 | def post_entries(self, opener, url, date, entries): 75 | data = [ 76 | ('__tcAction[saveTimesheet]', 'save'), 77 | ('date', date.strftime('%Y-%m-%d')), 78 | ] 79 | for entry in entries: 80 | data.append(('starthour[]', entry.start_time.strftime('%H'))) 81 | data.append(('startmin[]', entry.start_time.strftime('%M'))) 82 | data.append(('endhour[]', entry.end_time_or_now.strftime('%H'))) 83 | data.append(('endmin[]', entry.end_time_or_now.strftime('%M'))) 84 | data.append( 85 | ( 86 | 'mantisid[]', 87 | entry.ticket_number if entry.ticket_number else '' 88 | ) 89 | ) 90 | data.append(('description[]', entry.timesheet_description)) 91 | data.append(('debug[]', '1' if not entry.is_billable else '0')) 92 | 93 | data_encoded = urllib.urlencode(data) 94 | r = opener.open( 95 | "%s?date=%s" % ( 96 | url, 97 | date.strftime("%Y-%m-%d") 98 | ), data_encoded 99 | ) 100 | return r 101 | 102 | def get_entries(self, day): 103 | self.db.execute(""" 104 | SELECT 105 | id, 106 | start_time, 107 | COALESCE(end_time, STRFTIME('%s', 'now')) as end_time, 108 | description, 109 | ROUND( 110 | ( 111 | COALESCE(end_time, strftime('%s', 'now')) 112 | - start_time 113 | ) 114 | / CAST(3600 AS FLOAT), 2 115 | ) AS hours 116 | FROM 117 | entry 118 | WHERE 119 | start_time > STRFTIME('%s', ?, 'utc') 120 | AND 121 | start_time < STRFTIME('%s', ?, 'utc', '1 day') 122 | AND 123 | sheet = 'default' 124 | """, (day.strftime("%Y-%m-%d"), day.strftime("%Y-%m-%d"), )) 125 | results = self.db.fetchall() 126 | 127 | helper = ChiliprojectConnector( 128 | self.db, 129 | username=self.username, 130 | password=self.password 131 | ) 132 | 133 | final_results = [] 134 | for result in results: 135 | entry = TimesheetRow.from_row(result) 136 | entry.set_lookup_handler(helper) 137 | entry.set_meta( 138 | get_entry_meta(self.db, result[0]) 139 | ) 140 | final_results.append(entry) 141 | return final_results 142 | 143 | def login(self, login_url, username, password): 144 | opener = urllib2.build_opener(urllib2.HTTPCookieProcessor()) 145 | urllib2.install_opener(opener) 146 | data = urllib.urlencode(( 147 | ('username', username), 148 | ('password', password), 149 | )) 150 | response = opener.open(login_url, data) 151 | if response.read(): 152 | return opener 153 | else: 154 | raise AuthenticationError("The credentials you supplied appear to be incorrect.") 155 | 156 | def __enter__(self, *args, **kwargs): 157 | return self 158 | 159 | def __exit__(self, *args, **kwargs): 160 | return True 161 | -------------------------------------------------------------------------------- /timebook/chiliproject.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2011-2012 Adam Coddington 2 | # 3 | # Permission is hereby granted, free of charge, to any person obtaining 4 | # a copy of this software and associated documentation files (the 5 | # "Software"), to deal in the Software without restriction, including 6 | # without limitation the rights to use, copy, modify, merge, publish, 7 | # distribute, sublicense, and/or sell copies of the Software, and to 8 | # permit persons to whom the Software is furnished to do so, subject to 9 | # the following conditions: 10 | # 11 | # The above copyright notice and this permission notice shall be 12 | # included in all copies or substantial portions of the Software. 13 | # 14 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 17 | # IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 18 | # CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 19 | # TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 20 | # SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | 22 | import base64 23 | import json 24 | import urllib2 25 | 26 | from timebook import logger 27 | 28 | 29 | class ChiliprojectConnector(object): 30 | def __init__(self, db, username=None, password=None): 31 | self.db = db 32 | self.loaded = False 33 | 34 | self.domain = self.db.config.get_with_default( 35 | 'chiliproject', 36 | 'domain', 37 | 'chili.parthenonsoftware.com' 38 | ) 39 | 40 | self.issue_format = 'http://%s/issues/%%s.json' % self.domain 41 | if not username or not password: 42 | try: 43 | self.username = self.db.config.get("auth", "username") 44 | self.password = self.db.config.get("auth", "password") 45 | self.loaded = True 46 | except Exception as e: 47 | logger.exception(e) 48 | else: 49 | self.loaded = True 50 | self.username = username 51 | self.password = password 52 | 53 | def store_ticket_info_in_db(self, ticket_number, project, details): 54 | logger.debug("Storing ticket information for %s" % ticket_number) 55 | try: 56 | self.db.execute(""" 57 | INSERT INTO ticket_details (number, project, details) 58 | VALUES (?, ?, ?) 59 | """, (ticket_number, project, details, )) 60 | except sqlite3.OperationalError as e: 61 | logger.exception(e) 62 | 63 | def get_ticket_info_from_db(self, ticket_number): 64 | try: 65 | logger.debug("Checking in DB for %s" % ticket_number) 66 | return self.db.execute(""" 67 | SELECT project, details FROM ticket_details 68 | WHERE number = ? 69 | """, (ticket_number, )).fetchall()[0] 70 | except IndexError as e: 71 | logger.debug("No information in DB for %s" % ticket_number) 72 | return None 73 | 74 | def get_ticket_details(self, ticket_number): 75 | data = self.get_ticket_info_from_db(ticket_number) 76 | 77 | if data: 78 | return data 79 | if not data: 80 | logger.debug("Gathering data from Chiliproject API") 81 | try: 82 | request = urllib2.Request(self.issue_format % ticket_number) 83 | request.add_header( 84 | "Authorization", 85 | base64.encodestring( 86 | "%s:%s" % ( 87 | self.username, 88 | self.password 89 | ) 90 | ).replace("\n", "") 91 | ) 92 | result = urllib2.urlopen(request).read() 93 | data = json.loads(result) 94 | self.store_ticket_info_in_db( 95 | ticket_number, 96 | data["issue"]["project"]["name"], 97 | data["issue"]["subject"], 98 | ) 99 | return ( 100 | data["issue"]["project"]["name"], 101 | data["issue"]["subject"], 102 | ) 103 | except urllib2.HTTPError as e: 104 | logger.debug( 105 | "Encountered an HTTP Exception while " 106 | + "gathering data. %s" % e 107 | ) 108 | except Exception as e: 109 | logger.exception(e) 110 | 111 | def get_description_for_ticket(self, ticket_number): 112 | data = self.get_ticket_details(ticket_number) 113 | if data: 114 | return "%s: %s" % ( 115 | data[0], 116 | data[1] 117 | ) 118 | return None 119 | -------------------------------------------------------------------------------- /timebook/cmdline.py: -------------------------------------------------------------------------------- 1 | # cmdline.py 2 | # 3 | # Copyright (c) 2008-2009 Trevor Caira, 2011-2012 Adam Coddington 4 | # 5 | # Permission is hereby granted, free of charge, to any person obtaining 6 | # a copy of this software and associated documentation files (the 7 | # "Software"), to deal in the Software without restriction, including 8 | # without limitation the rights to use, copy, modify, merge, publish, 9 | # distribute, sublicense, and/or sell copies of the Software, and to 10 | # permit persons to whom the Software is furnished to do so, subject to 11 | # the following conditions: 12 | # 13 | # The above copyright notice and this permission notice shall be 14 | # included in all copies or substantial portions of the Software. 15 | # 16 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | # IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 20 | # CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 21 | # TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 22 | # SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | import locale 25 | from optparse import OptionParser 26 | 27 | from timebook import get_version 28 | from timebook.db import Database 29 | from timebook.commands import commands, run_command 30 | from timebook.config import parse_config 31 | from timebook.cmdutil import AmbiguousLookup, NoMatch 32 | from timebook.exceptions import CommandError 33 | 34 | from timebook import CONFIG_FILE, TIMESHEET_DB 35 | 36 | DEFAULTS = {'config': CONFIG_FILE, 37 | 'timebook': TIMESHEET_DB, 38 | 'encoding': locale.getpreferredencoding()} 39 | 40 | 41 | def make_parser(): 42 | cmd_descs = ['%s - %s' % (k, commands[k].description) for k 43 | in sorted(commands)] 44 | parser = OptionParser(usage='''usage: %%prog [OPTIONS] COMMAND \ 45 | [ARGS...] 46 | 47 | where COMMAND is one of: 48 | %s''' % '\n '.join(cmd_descs), version=get_version()) 49 | parser.disable_interspersed_args() 50 | parser.add_option('-C', '--config', dest='config', 51 | default=DEFAULTS['config'], help='Specify an \ 52 | alternate configuration file (default: "%s").' % DEFAULTS['config']) 53 | parser.add_option('-b', '--timebook', dest='timebook', 54 | default=DEFAULTS['timebook'], help='Specify an \ 55 | alternate timebook file (default: "%s").' % DEFAULTS['timebook']) 56 | parser.add_option('-e', '--encoding', dest='encoding', 57 | default=DEFAULTS['encoding'], help='Specify an \ 58 | alternate encoding to decode command line options and arguments (default: \ 59 | "%s")' % DEFAULTS['encoding']) 60 | return parser 61 | 62 | 63 | def parse_options(parser): 64 | options, args = parser.parse_args() 65 | encoding = options.__dict__.pop('encoding') 66 | try: 67 | options.__dict__ = dict((k, v.decode(encoding)) for (k, v) in 68 | options.__dict__.iteritems()) 69 | args = [a.decode(encoding) for a in args] 70 | except LookupError: 71 | parser.error('unknown encoding %s' % encoding) 72 | 73 | if len(args) < 1: 74 | # default to ``t now`` 75 | args = ['now'] + args 76 | return options, args 77 | 78 | 79 | def run_from_cmdline(): 80 | parser = make_parser() 81 | options, args = parse_options(parser) 82 | config = parse_config(options.config) 83 | db = Database(options.timebook, config) 84 | cmd, args = args[0], args[1:] 85 | try: 86 | run_command(db, cmd, args) 87 | except NoMatch as e: 88 | parser.error('%s' % e.args[0]) 89 | except AmbiguousLookup as e: 90 | parser.error('%s\n %s' % (e.args[0], ' '.join(e.args[1]))) 91 | except CommandError as e: 92 | parser.error("%s" % e) 93 | -------------------------------------------------------------------------------- /timebook/cmdutil.py: -------------------------------------------------------------------------------- 1 | # cmdutil.py 2 | # 3 | # Copyright (c) 2008-2009 Trevor Caira, 2011-2012 Adam Coddington 4 | # 5 | # Permission is hereby granted, free of charge, to any person obtaining 6 | # a copy of this software and associated documentation files (the 7 | # "Software"), to deal in the Software without restriction, including 8 | # without limitation the rights to use, copy, modify, merge, publish, 9 | # distribute, sublicense, and/or sell copies of the Software, and to 10 | # permit persons to whom the Software is furnished to do so, subject to 11 | # the following conditions: 12 | # 13 | # The above copyright notice and this permission notice shall be 14 | # included in all copies or substantial portions of the Software. 15 | # 16 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | # IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 20 | # CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 21 | # TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 22 | # SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | import datetime 25 | import time 26 | import re 27 | 28 | 29 | from timebook import dbutil 30 | 31 | 32 | class AmbiguousLookup(ValueError): 33 | pass 34 | 35 | 36 | class NoMatch(ValueError): 37 | pass 38 | 39 | 40 | def complete(it, lookup, key_desc): 41 | partial_match = None 42 | for i in it: 43 | if i == lookup: 44 | return i 45 | if i.startswith(lookup): 46 | if partial_match is not None: 47 | matches = sorted(i for i in it if i.startswith(lookup)) 48 | raise AmbiguousLookup('ambiguous %s "%s":' % 49 | (key_desc, lookup), matches) 50 | partial_match = i 51 | if partial_match is None: 52 | raise NoMatch('no such %s "%s".' % (key_desc, lookup)) 53 | else: 54 | return partial_match 55 | 56 | 57 | def pprint_table(table, footer_row=False): 58 | if footer_row: 59 | check = table[:-1] 60 | else: 61 | check = table 62 | widths = [3 + max(len(row[col]) for row in check) for col 63 | in xrange(len(table[0]))] 64 | for row in table: 65 | # Don't pad the final column 66 | first_cols = [cell + ' ' * (spacing - len(cell)) 67 | for (cell, spacing) in zip(row[:-1], widths[:-1])] 68 | print ''.join(first_cols + [row[-1]]) 69 | 70 | today_str = time.strftime("%Y-%m-%d", datetime.datetime.now().timetuple()) 71 | matches = [(re.compile(r'^\d+:\d+$'), today_str + " ", ":00"), 72 | (re.compile(r'^\d+:\d+:\d+$'), today_str + " ", ""), 73 | (re.compile(r'^\d+-\d+-\d+$'), "", " 00:00:00"), 74 | (re.compile(r'^\d+-\d+-\d+\s+\d+:\d+$'), "", ":00"), 75 | (re.compile(r'^\d+-\d+-\d+\s+\d+:\d+:\d+$'), "", ""), 76 | ] 77 | fmt = "%Y-%m-%d %H:%M:%S" 78 | offset_regexp = re.compile( 79 | r'(?:(?P\d+)d)? ?(?:(?P\d+)h)? ?(?:(?P\d+)m)? ?(?:(?P\d+)s)?' 80 | ) 81 | 82 | 83 | def _rawinput_date_format(message, fmt, default): 84 | try: 85 | result = raw_input("%s (%s):\t" % ( 86 | message, 87 | "\"%s\"" % default.strftime(fmt) if default else 'None' 88 | )) 89 | if not result: 90 | return default 91 | return datetime.datetime.strptime( 92 | result, 93 | fmt 94 | ) 95 | except ValueError: 96 | return False 97 | 98 | 99 | def rawinput_date_format(message, fmt, default): 100 | while True: 101 | result = _rawinput_date_format(message, fmt, default) 102 | if result != False: 103 | return result 104 | 105 | 106 | def get_time_offset(offset_str): 107 | raw = offset_regexp.search(offset_str).groupdict() 108 | groups = dict((k, int(v)) for k, v in raw.iteritems() if v is not None) 109 | if groups: 110 | return datetime.timedelta(**groups) 111 | raise ValueError('%s is not a valid offset' % offset_str) 112 | 113 | 114 | def parse_date_time(dt_str): 115 | for (patt, prepend, postpend) in matches: 116 | if patt.match(dt_str): 117 | res = time.strptime(prepend + dt_str + postpend, fmt) 118 | return int(time.mktime(res)) 119 | raise ValueError("%s is not in a valid time format" % dt_str) 120 | 121 | 122 | def parse_date_time_or_now(dt_str): 123 | if dt_str: 124 | return parse_date_time(dt_str) 125 | else: 126 | return int(time.time()) 127 | 128 | 129 | def timedelta_hms_display(timedelta): 130 | hours = timedelta.days * 24 + timedelta.seconds / 3600 131 | minutes = timedelta.seconds / 60 % 60 132 | seconds = timedelta.seconds % 60 133 | return '%02d:%02d:%02d' % (hours, minutes, seconds) 134 | 135 | 136 | def collect_user_specified_attributes(db, opts): 137 | meta = {} 138 | if db.config.has_section('custom_ticket_meta'): 139 | for key in db.config.options('custom_ticket_meta'): 140 | value = getattr(opts, key) 141 | if value != None: 142 | meta[key] = value 143 | return meta 144 | 145 | 146 | def add_user_specified_attributes(db, parser): 147 | if db.config.has_section('custom_ticket_meta'): 148 | for key in db.config.options('custom_ticket_meta'): 149 | help_text=db.config.get('custom_ticket_meta', key) 150 | parser.add_option('--' + key, type='string', dest=key, 151 | help=help_text, default=None 152 | ) 153 | -------------------------------------------------------------------------------- /timebook/commands.py: -------------------------------------------------------------------------------- 1 | # commands.py 2 | # 3 | # Copyright (c) 2008-2009 Trevor Caira, 2011-2012 Adam Coddington 4 | # 5 | # Permission is hereby granted, free of charge, to any person obtaining 6 | # a copy of this software and associated documentation files (the 7 | # "Software"), to deal in the Software without restriction, including 8 | # without limitation the rights to use, copy, modify, merge, publish, 9 | # distribute, sublicense, and/or sell copies of the Software, and to 10 | # permit persons to whom the Software is furnished to do so, subject to 11 | # the following conditions: 12 | # 13 | # The above copyright notice and this permission notice shall be 14 | # included in all copies or substantial portions of the Software. 15 | # 16 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | # IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 20 | # CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 21 | # TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 22 | # SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | from datetime import datetime, timedelta 25 | from functools import wraps 26 | from gettext import ngettext 27 | import calendar 28 | import hashlib 29 | import httplib 30 | import json 31 | import os 32 | import optparse 33 | import re 34 | import shlex 35 | import subprocess 36 | import sys 37 | import time 38 | from urllib import urlencode 39 | from urlparse import urlparse 40 | 41 | from timebook import logger, dbutil, cmdutil, exceptions 42 | from timebook.autopost import TimesheetPoster 43 | from timebook.payperiodutil import PayPeriodUtil 44 | from timebook.cmdutil import rawinput_date_format 45 | 46 | commands = {} 47 | cmd_aliases = {} 48 | 49 | 50 | def pre_hook(db, func_name, args, kwargs): 51 | current_sheet = dbutil.get_current_sheet(db) 52 | keys_to_check = [ 53 | 'pre_%s_hook' % func_name, 54 | 'pre_hook', 55 | ] 56 | for key_name in keys_to_check: 57 | if db.config.has_option(current_sheet, key_name): 58 | command = shlex.split( 59 | db.config.get(current_sheet, key_name), 60 | ) 61 | res = subprocess.call( 62 | command + args, 63 | ) 64 | if res != 0: 65 | raise exceptions.PreHookException( 66 | "%s (%s)(%s)" % (command, func_name, ', '.join(args)) 67 | ) 68 | return True 69 | 70 | 71 | def post_hook(db, func_name, args, kwargs, res): 72 | current_sheet = dbutil.get_current_sheet(db) 73 | keys_to_check = [ 74 | 'post_%s_hook' % func_name, 75 | 'post_hook', 76 | ] 77 | for key_name in keys_to_check: 78 | if db.config.has_option(current_sheet, key_name): 79 | command = shlex.split( 80 | db.config.get(current_sheet, key_name), 81 | ) 82 | res = subprocess.call( 83 | command + args + [str(res)] 84 | ) 85 | if res != 0: 86 | raise exceptions.PostHookException( 87 | "%s (%s)(%s)(%s)" % 88 | (command, func_name, ', '.join(args), res) 89 | ) 90 | return True 91 | 92 | 93 | def command(desc, name=None, aliases=(), locking=True, read_only=False): 94 | def decorator(func): 95 | func_name = name or func.func_code.co_name 96 | commands[func_name] = func 97 | func.description = desc 98 | func.locking = locking 99 | func.read_only = read_only 100 | for alias in aliases: 101 | cmd_aliases[alias] = func_name 102 | 103 | @wraps(func) 104 | def decorated(db, args, **kwargs): 105 | try: 106 | pre_hook(db, func_name, args, kwargs) 107 | res = func(db, args, **kwargs) 108 | post_hook(db, func_name, args, kwargs, res) 109 | except exceptions.PreHookException as e: 110 | print "Error, command aborted. Pre hook failed: %s" % e 111 | raise e 112 | except exceptions.PostHookException as e: 113 | print "Warning. Post hook failed: %s" % e 114 | raise e 115 | commands[func_name] = decorated 116 | return decorated 117 | return decorator 118 | 119 | 120 | def get_command_by_user_configured_alias(db, cmd): 121 | if ( 122 | not db.config.has_section('aliases') 123 | or not db.config.has_option('aliases', cmd) 124 | ): 125 | return 126 | alias = db.config.get('aliases', cmd) 127 | if alias in commands.keys(): 128 | return alias 129 | else: 130 | raise exceptions.CommandError("The alias '%s' is mapped to a function that does not exist." % cmd) 131 | 132 | 133 | def get_command_by_name(db, cmd): 134 | func = get_command_by_user_configured_alias(db, cmd) 135 | if func: 136 | return func 137 | func = cmd_aliases.get(cmd, None) 138 | if func: 139 | return func 140 | return cmdutil.complete(commands, cmd, 'command') 141 | 142 | 143 | def run_command(db, cmd, args): 144 | func = get_command_by_name(db, cmd) 145 | try: 146 | if commands[func].locking: 147 | db.execute(u'begin') 148 | commands[func](db, args) 149 | if commands[func].locking: 150 | db.execute(u'commit') 151 | current_sheet = dbutil.get_current_sheet(db) 152 | if not commands[func].read_only: 153 | if db.config.has_option( 154 | current_sheet, 155 | 'reporting_url' 156 | ): 157 | current_info = dbutil.get_active_info(db, current_sheet) 158 | status_string = dbutil.get_status_string( 159 | db, 160 | current_sheet, 161 | exclude=['billable'] 162 | ) 163 | report_to_url( 164 | db.config.get(current_sheet, 'reporting_url'), 165 | None, 166 | status_string, 167 | ( 168 | datetime.utcnow() 169 | - timedelta(seconds=current_info[0]) 170 | ).strftime("%Y-%m-%d %H:%M:%S") 171 | if current_info else '', 172 | datetime.now() - timedelta(seconds=current_info[0]) if current_info else timedelta(seconds=0), 173 | current_info[0] if current_info else 0, 174 | cmd, 175 | args 176 | ) 177 | except Exception: 178 | import traceback 179 | traceback.print_exc() 180 | if commands[func].locking: 181 | db.execute(u'rollback') 182 | raise 183 | 184 | 185 | def report_to_url(url, user, current, since_str, since, seconds, command, args): 186 | try: 187 | url_data = urlparse(url) 188 | h = httplib.HTTPConnection(url_data.netloc) 189 | if current: 190 | message = { 191 | 'message': ( 192 | '[Since %s]\r%s' % ( 193 | since.strftime('%H:%M'), 194 | current, 195 | ) 196 | ), 197 | } 198 | h.request("PUT", url_data.path, json.dumps(message), { 199 | "Content-type": "application/json", 200 | }) 201 | else: 202 | h.request("DELETE", url_data.path, '', { 203 | "Content-type": "application/json", 204 | }) 205 | response = h.getresponse() 206 | content = response.read() 207 | if response.status != 200: 208 | raise exceptions.ReportingException( 209 | ( 210 | "HTTP Error %s encountered while posting reporting " 211 | "information." 212 | ) % response.status 213 | ) 214 | if len(content) > 0: 215 | print content 216 | except Exception as e: 217 | print e 218 | 219 | 220 | def get_date_from_cli_string(option, option_str, value, parser): 221 | if(value == 'today'): 222 | the_date = datetime.now().date() 223 | elif(value == 'yesterday'): 224 | the_date = (datetime.now() + timedelta(days=-1)).date() 225 | elif(re.match("^-\d+$", value)): 226 | the_date = (datetime.now() + timedelta(days=int(value))).date() 227 | elif(re.match("^\d{4}-\d{2}-\d{2}$", value)): 228 | try: 229 | the_date = datetime.strptime(value, "%Y-%m-%d").date() 230 | except Exception as e: 231 | raise optparse.OptionValueError( 232 | "'%s' does not match format YYYY-MM-DD" % value 233 | ) 234 | else: 235 | raise optparse.OptionValueError("Unrecognized date argument.") 236 | setattr(parser.values, option.dest, the_date) 237 | 238 | # Commands 239 | 240 | 241 | @command("open the backend's interactive shell", aliases=('shell',), 242 | locking=False) 243 | def backend(db, args): 244 | parser = optparse.OptionParser(usage='''usage: %prog backend 245 | 246 | Run an interactive database session on the timebook database. Requires 247 | the sqlite3 command.''') 248 | subprocess.call(('sqlite3', db.path)) 249 | 250 | 251 | @command('start a new task on the current timesheet', name='change') 252 | def change(db, args, extra=None): 253 | out(db, []) 254 | in_(db, args, change=True) 255 | 256 | 257 | @command('post timesheet hours to timesheet online', 258 | name='post', locking=False) 259 | def post(db, args, extra=None): 260 | parser = optparse.OptionParser() 261 | parser.add_option("--date", type="string", action="callback", 262 | help='''Date for which to post timesheet entries for. 263 | 264 | Must be in either YYYY-MM-DD format, be the string 'yesterday' or 'today', 265 | or be a negative number indicating the number of days ago for which to run 266 | the report. (default: today)''', 267 | callback=get_date_from_cli_string, 268 | dest="date", 269 | default=datetime.now().date() 270 | ) 271 | (options, args, ) = parser.parse_args() 272 | 273 | with TimesheetPoster( 274 | db, 275 | options.date, 276 | ) as app: 277 | try: 278 | app.main() 279 | except Exception, e: 280 | raise e 281 | 282 | 283 | @command( 284 | 'monitors for taskwarrior changes', 285 | aliases=('watch_tasks', 'task'), 286 | locking=False 287 | ) 288 | def taskwarrior(db, args, extra=None): 289 | def poll_taskwarrior(): 290 | tasks = [] 291 | results = subprocess.check_output( 292 | [ 293 | "task", 294 | "export", 295 | "status:pending", 296 | "start.not:", 297 | ] 298 | ) 299 | for line in results.splitlines(): 300 | if len(line.strip()) > 0: 301 | tasks.append( 302 | json.loads(line.rstrip(',')) 303 | ) 304 | return ( 305 | tasks, 306 | hashlib.md5('|'.join([t.get('uuid') for t in tasks])).hexdigest() 307 | ) 308 | 309 | logger.info("Watching taskwarrior output...") 310 | task_hash_status = '' 311 | while True: 312 | tasks, task_hash = poll_taskwarrior() 313 | args = [] 314 | command = 'change' 315 | do_change = False 316 | value = dbutil.get_current_active_info(db) 317 | if not value and task_hash_status != '': 318 | logger.error("Clocked-out.") 319 | task_hash_status = '' 320 | elif value and task_hash != task_hash_status: 321 | task_hash_status = task_hash 322 | if tasks: 323 | if len(tasks) > 1: 324 | logger.warning( 325 | "Multiple tasks currently active; using first." 326 | ) 327 | task = tasks[0] 328 | logger.info("Active task changed: %s" % task) 329 | 330 | # Ticket No. 331 | ticket = task.get('ticket') 332 | if ticket: 333 | args.append('--ticket=%s' % ticket) 334 | 335 | # Pull Request No. 336 | pr = task.get('pr') 337 | if pr: 338 | args.append('--pr=%s' % pr.replace('/', ':')) 339 | 340 | # Description 341 | description = task.get('description') 342 | if description: 343 | args.append(description) 344 | 345 | _, duration = value 346 | logger.error(duration) 347 | if duration < 60: 348 | command = 'alter' 349 | 350 | do_change = True 351 | else: 352 | logger.warning("No active tasks; changing to nil.") 353 | do_change = True 354 | 355 | if do_change: 356 | logger.info("Running %s %s" % (command, args)) 357 | run_command(db, command, args) 358 | time.sleep(1) 359 | 360 | 361 | @command('provides hours information for the current pay period', name='hours', 362 | aliases=('payperiod', 'pay', 'period', 'offset', ), read_only=True) 363 | def hours(db, args, extra=None): 364 | payperiod_class = 'MonthlyOnSecondToLastFriday' 365 | current_sheet = dbutil.get_current_sheet(db) 366 | if db.config.has_option(current_sheet, 'payperiod_type'): 367 | payperiod_class = db.config.get(current_sheet, 'payperiod_type') 368 | 369 | parser = optparse.OptionParser() 370 | parser.add_option("--param", type="string", dest="param", default=None) 371 | parser.add_option( 372 | "--payperiod-type", type="string", 373 | dest="payperiod_type", default=payperiod_class 374 | ) 375 | (options, args, ) = parser.parse_args() 376 | 377 | ppu = PayPeriodUtil(db, options.payperiod_type) 378 | hour_info = ppu.get_hours_details() 379 | if options.param and options.param in hour_info.keys(): 380 | param = hour_info[options.param] 381 | if isinstance(param, datetime): 382 | param = int(time.mktime(param.timetuple())) 383 | print param 384 | else: 385 | print "Period: %s through %s" % ( 386 | hour_info['begin_period'].strftime("%Y-%m-%d"), 387 | hour_info['end_period'].strftime("%Y-%m-%d"), 388 | ) 389 | 390 | if(hour_info['actual'] > hour_info['expected']): 391 | print "%.2f hour SURPLUS" % ( 392 | hour_info['actual'] - hour_info['expected'], 393 | ) 394 | print "%s hours unpaid" % (hour_info['unpaid'],) 395 | print "%s hours vacation" % (hour_info['vacation'], ) 396 | print "%s hours adjustment" % (hour_info['adjustments'], ) 397 | print "" 398 | print "You should have left at %s today to maintain hours." % ( 399 | hour_info['out_time'].strftime("%H:%M"), 400 | ) 401 | else: 402 | print "%.2f hour DEFICIT" % ( 403 | hour_info['expected'] - hour_info['actual'] 404 | ) 405 | print "%s hours unpaid" % (hour_info['unpaid']) 406 | print "%s hours vacation" % (hour_info['vacation'], ) 407 | print "%s hours adjustment" % (hour_info['adjustments'], ) 408 | print "" 409 | print "You should leave at %s today to maintain hours." % ( 410 | hour_info['out_time'].strftime("%H:%M"), 411 | ) 412 | 413 | 414 | @command('start the timer for the current timesheet', name='in', 415 | aliases=('start',)) 416 | def in_(db, args, extra=None, change=False): 417 | parser = optparse.OptionParser(usage='''usage: %prog in [NOTES...] 418 | 419 | Start the timer for the current timesheet. Must be called before out. 420 | Notes may be specified for this period. This is exactly equivalent to 421 | %prog in; %prog alter''') 422 | parser.add_option('-s', '--switch', dest='switch', type='string', 423 | help='Switch to another timesheet before starting the timer.' 424 | ) 425 | parser.add_option('-o', '--out', dest='out', action='store_true', 426 | default=False, help='Clocks out before clocking in' 427 | ) 428 | parser.add_option('-a', '--at', dest='at', type='string', 429 | help='Set time of clock-in' 430 | ) 431 | parser.add_option('-t', '--ticket', dest='ticket_number', type='string', 432 | default=None, help='Set ticket number' 433 | ) 434 | parser.add_option('--billable', dest='billable', action='store_true', 435 | default=True, help='Marks entry as billable' 436 | ) 437 | parser.add_option('--non-billable', dest='billable', action='store_false', 438 | default=True, help='Marks entry as non-billable' 439 | ) 440 | cmdutil.add_user_specified_attributes(db, parser) 441 | opts, args = parser.parse_args(args=args) 442 | metadata = cmdutil.collect_user_specified_attributes(db, opts) 443 | metadata['billable'] = 'yes' if opts.billable else 'no' 444 | if opts.ticket_number: 445 | metadata['ticket_number'] = opts.ticket_number 446 | if opts.switch: 447 | sheet = opts.switch 448 | switch(db, [sheet]) 449 | else: 450 | sheet = dbutil.get_current_sheet(db) 451 | timestamp = cmdutil.parse_date_time_or_now(opts.at) 452 | if opts.out: 453 | clock_out(db, timestamp=timestamp) 454 | running = dbutil.get_active_info(db, sheet) 455 | if running is not None: 456 | raise SystemExit('error: timesheet already active') 457 | most_recent_clockout = dbutil.get_most_recent_clockout(db, sheet) 458 | description = u' '.join(args) or None 459 | if most_recent_clockout: 460 | (id, start_time, prev_timestamp, prev_desc) = most_recent_clockout 461 | prev_meta = dbutil.get_entry_meta(db, id) 462 | if timestamp < prev_timestamp: 463 | raise SystemExit('error: time periods could end up overlapping') 464 | current_sheet = dbutil.get_current_sheet(db) 465 | if change and db.config.has_option(current_sheet, 'autocontinue'): 466 | if not description: 467 | description = prev_desc 468 | for p_key, p_value in prev_meta.items(): 469 | if p_key not in metadata.keys() or not metadata[p_key]: 470 | metadata[p_key] = p_value 471 | 472 | db.execute(u''' 473 | insert into entry ( 474 | sheet, start_time, description, extra 475 | ) values (?,?,?,?) 476 | ''', (sheet, timestamp, description, extra)) 477 | entry_id = db.cursor.lastrowid 478 | dbutil.update_entry_meta(db, entry_id, metadata) 479 | 480 | 481 | @command('delete a timesheet', aliases=('delete',)) 482 | def kill(db, args): 483 | parser = optparse.OptionParser(usage='''usage: %prog kill [TIMESHEET] 484 | 485 | Delete a timesheet. If no timesheet is specified, delete the current 486 | timesheet and switch to the default timesheet.''') 487 | opts, args = parser.parse_args(args=args) 488 | current = dbutil.get_current_sheet(db) 489 | if args: 490 | to_delete = args[0] 491 | switch_to_default = False 492 | else: 493 | to_delete = current 494 | switch_to_default = True 495 | try: 496 | yes_answers = ('y', 'yes') 497 | # Use print to display the prompt since it intelligently decodes 498 | # unicode strings. 499 | print (u'delete timesheet %s?' % to_delete), 500 | confirm = raw_input('').strip().lower() in yes_answers 501 | except (KeyboardInterrupt, EOFError): 502 | confirm = False 503 | print 504 | if not confirm: 505 | print 'canceled' 506 | return 507 | db.execute(u'delete from entry where sheet = ?', (to_delete,)) 508 | if switch_to_default: 509 | switch(db, ['default']) 510 | 511 | 512 | @command('show the available timesheets', aliases=('ls',), read_only=True) 513 | def list(db, args): 514 | parser = optparse.OptionParser(usage='''usage: %prog list 515 | 516 | List the available timesheets.''') 517 | parser.add_option('-s', '--simple', dest='simple', 518 | action='store_true', help='Only display the names \ 519 | of the available timesheets.') 520 | opts, args = parser.parse_args(args=args) 521 | 522 | if opts.simple: 523 | db.execute( 524 | u''' 525 | select 526 | distinct sheet 527 | from 528 | entry 529 | order by 530 | sheet asc; 531 | ''') 532 | print u'\n'.join(r[0] for r in db.fetchall()) 533 | return 534 | 535 | table = [[' Timesheet', 'Running', 'Today', 'Total time']] 536 | db.execute(u''' 537 | select 538 | e1.sheet as name, 539 | e1.sheet = meta.value as is_current, 540 | ifnull((select 541 | strftime('%s', 'now') - e2.start_time 542 | from 543 | entry e2 544 | where 545 | e1.sheet = e2.sheet and e2.end_time is null), 0 546 | ) as active, 547 | (select 548 | ifnull(sum(ifnull(e3.end_time, strftime('%s', 'now')) - 549 | e3.start_time), 0) 550 | from 551 | entry e3 552 | where 553 | e1.sheet = e3.sheet and 554 | e3.start_time > strftime('%s', date('now')) 555 | ) as today, 556 | ifnull(sum(ifnull(e1.end_time, strftime('%s', 'now')) - 557 | e1.start_time), 0) as total 558 | from 559 | entry e1, meta 560 | where 561 | meta.key = 'current_sheet' 562 | group by e1.sheet 563 | order by e1.sheet asc; 564 | ''') 565 | sheets = db.fetchall() 566 | if len(sheets) == 0: 567 | print u'(no sheets)' 568 | return 569 | for (name, is_current, active, today, total) in sheets: 570 | cur_name = '%s%s' % ('*' if is_current else ' ', name) 571 | active = str(timedelta(seconds=active)) if active != 0 \ 572 | else '--' 573 | today = str(timedelta(seconds=today)) 574 | total_time = str(timedelta(seconds=total)) 575 | table.append([cur_name, active, today, total_time]) 576 | cmdutil.pprint_table(table) 577 | 578 | 579 | @command('switch to a new timesheet', read_only=True) 580 | def switch(db, args): 581 | parser = optparse.OptionParser(usage='''usage: %prog switch TIMESHEET 582 | 583 | Switch to a new timesheet. This causes all future operation (except switch) 584 | to operate on that timesheet. The default timesheet is called 585 | "default".''') 586 | parser.add_option('-v', '--verbose', dest='verbose', 587 | action='store_true', help='Print the name and \ 588 | number of entries of the timesheet.') 589 | opts, args = parser.parse_args(args=args) 590 | if len(args) != 1: 591 | parser.error('no timesheet given') 592 | 593 | sheet = args[0] 594 | 595 | # optimization: check that the given timesheet is not already 596 | # current. updates are far slower than selects. 597 | if dbutil.get_current_sheet(db) != sheet: 598 | db.execute(u''' 599 | update 600 | meta 601 | set 602 | value = ? 603 | where 604 | key = 'current_sheet' 605 | ''', (args[0],)) 606 | 607 | if opts.verbose: 608 | entry_count = dbutil.get_entry_count(db, sheet) 609 | if entry_count == 0: 610 | print u'switched to empty timesheet "%s"' % sheet 611 | else: 612 | print ngettext( 613 | u'switched to timesheet "%s" (1 entry)' % sheet, 614 | u'switched to timesheet "%s" (%s entries)' % ( 615 | sheet, entry_count), entry_count) 616 | 617 | 618 | @command('stop the timer for the current timesheet', aliases=('stop',)) 619 | def out(db, args): 620 | parser = optparse.OptionParser(usage='''usage: %prog out 621 | 622 | Stop the timer for the current timesheet. Must be called after in.''') 623 | parser.add_option('-v', '--verbose', dest='verbose', 624 | action='store_true', help='Show the duration of \ 625 | the period that the out command ends.') 626 | parser.add_option('-a', '--at', dest='at', 627 | help='Set time of clock-out') 628 | parser.add_option('--all', dest='all_out', action='store_true', 629 | default=False, help='Clock out of all timesheets') 630 | opts, args = parser.parse_args(args=args) 631 | if args: 632 | parser.error('"t out" takes no arguments.') 633 | clock_out(db, opts.at, opts.verbose, all_out=opts.all_out) 634 | 635 | try: 636 | value = db.config.get('automation', 'post_on_clockout') 637 | if value: 638 | post(db, []) 639 | except Exception: 640 | pass 641 | 642 | 643 | def clock_out(db, at=None, verbose=False, timestamp=None, all_out=False): 644 | if not timestamp: 645 | timestamp = cmdutil.parse_date_time_or_now(at) 646 | active = dbutil.get_current_start_time(db) 647 | if active is None: 648 | raise SystemExit('error: timesheet not active') 649 | active_id, start_time = active 650 | active_time = timestamp - start_time 651 | if verbose: 652 | print timedelta(seconds=active_time) 653 | if active_time < 0: 654 | raise SystemExit("Error: Negative active time") 655 | if all_out: 656 | db.execute(u''' 657 | UPDATE 658 | entry 659 | SET 660 | end_time = ? 661 | WHERE 662 | end_time is null 663 | ''', (timestamp, )) 664 | else: 665 | db.execute(u''' 666 | update 667 | entry 668 | set 669 | end_time = ? 670 | where 671 | entry.id = ? 672 | ''', (timestamp, active_id)) 673 | 674 | 675 | @command('create a new timebook entry and backdate it') 676 | def backdate(db, args): 677 | try: 678 | try: 679 | now_dt = datetime.now() 680 | offset = cmdutil.get_time_offset(args[0]) 681 | start = datetime( 682 | now_dt.year, 683 | now_dt.month, 684 | now_dt.day, 685 | now_dt.hour, 686 | now_dt.minute, 687 | now_dt.second 688 | ) - offset 689 | except ValueError: 690 | start = datetime.fromtimestamp(cmdutil.parse_date_time(args[0])) 691 | args = args[1:] 692 | 693 | active = dbutil.get_current_start_time(db) 694 | if active: 695 | clock_out(db) 696 | 697 | sql = """ 698 | SELECT id 699 | FROM entry 700 | WHERE 701 | sheet = ? 702 | AND 703 | end_time > ? 704 | """ 705 | sql_args = ( 706 | dbutil.get_current_sheet(db), 707 | int(time.mktime(start.timetuple())), 708 | ) 709 | db.execute(sql, sql_args) 710 | 711 | rows = db.fetchall() 712 | 713 | if len(rows) > 1: 714 | raise exceptions.CommandError( 715 | '%s overlaps %s entries. ' 716 | 'Please select a later time to backdate to.' % ( 717 | start, 718 | len(rows) 719 | ) 720 | ) 721 | 722 | sql = """ 723 | UPDATE entry 724 | SET end_time = ? 725 | WHERE 726 | sheet = ? 727 | AND 728 | end_time > ? 729 | """ 730 | sql_args = ( 731 | int(time.mktime(start.timetuple())), 732 | dbutil.get_current_sheet(db), 733 | int(time.mktime(start.timetuple())), 734 | ) 735 | db.execute(sql, sql_args) 736 | 737 | # Clock in 738 | args.extend( 739 | ['--at', str(start)] 740 | ) 741 | in_(db, args) 742 | except IndexError as e: 743 | print ( 744 | "Backdate requires at least one argument: START. " 745 | "Please use either the format \"YYY-MM-DD HH:MM\" or " 746 | "a time offset like '1h 20m'." 747 | ) 748 | logger.exception(e) 749 | 750 | 751 | 752 | @command('alter the description of the active period', aliases=('write',)) 753 | def alter(db, args): 754 | parser = optparse.OptionParser(usage='''usage: %prog alter NOTES... 755 | 756 | Inserts a note associated with the currently active period in the \ 757 | timesheet. For example, ``t alter Documenting timebook.``''') 758 | parser.add_option('-t', '--ticket', dest='ticket_number', type='string', 759 | default=None, help='Set ticket number' 760 | ) 761 | parser.add_option('--billable', dest='billable', action='store_true', 762 | default=None, help='Marks entry as billable' 763 | ) 764 | parser.add_option('--non-billable', dest='billable', action='store_false', 765 | default=None, help='Marks entry as billable' 766 | ) 767 | parser.add_option('--id', dest='entry_id', type='string', 768 | default=None, help='Entry ID number (defaults to current)' 769 | ) 770 | cmdutil.add_user_specified_attributes(db, parser) 771 | opts, args = parser.parse_args(args=args) 772 | 773 | if not opts.entry_id: 774 | active = dbutil.get_current_active_info(db) 775 | if active is None: 776 | raise SystemExit('error: timesheet not active') 777 | entry_id = active[0] 778 | else: 779 | entry_id = opts.entry_id 780 | if args: 781 | db.execute(u''' 782 | update 783 | entry 784 | set 785 | description = ? 786 | where 787 | entry.id = ? 788 | ''', (' '.join(args), entry_id)) 789 | meta = cmdutil.collect_user_specified_attributes(db, opts) 790 | if opts.billable != None: 791 | meta['billable'] = 'yes' if opts.billable else 'no' 792 | if opts.ticket_number != None: 793 | meta['ticket_number'] = opts.ticket_number 794 | dbutil.update_entry_meta(db, entry_id, meta) 795 | 796 | 797 | @command('show all running timesheets', aliases=('active',), read_only=True) 798 | def running(db, args): 799 | parser = optparse.OptionParser(usage='''usage: %prog running 800 | 801 | Print all active sheets and any messages associated with them.''') 802 | opts, args = parser.parse_args(args=args) 803 | db.execute(u''' 804 | select 805 | entry.sheet, 806 | ifnull(entry.description, '--') 807 | from 808 | entry 809 | where 810 | entry.end_time is null 811 | order by 812 | entry.sheet asc; 813 | ''') 814 | cmdutil.pprint_table([(u'Timesheet', u'Description')] + db.fetchall()) 815 | 816 | 817 | @command('show the status of the current timesheet', 818 | aliases=('info',), read_only=True) 819 | def now(db, args): 820 | parser = optparse.OptionParser(usage='''usage: %prog now [TIMESHEET] 821 | 822 | Print the current sheet, whether it's active, and if so, how long it 823 | has been active and what notes are associated with the current 824 | period. 825 | 826 | If a specific timesheet is given, display the same information for that 827 | timesheet instead.''') 828 | parser.add_option('-s', '--simple', dest='simple', 829 | action='store_true', help='Only display the name \ 830 | of the current timesheet.') 831 | opts, args = parser.parse_args(args=args) 832 | 833 | if opts.simple: 834 | print dbutil.get_current_sheet(db) 835 | return 836 | 837 | if args: 838 | sheet = cmdutil.complete(dbutil.get_sheet_names(db), args[0], 839 | 'timesheet') 840 | else: 841 | sheet = dbutil.get_current_sheet(db) 842 | 843 | entry_count = dbutil.get_entry_count(db, sheet) 844 | if entry_count == 0: 845 | raise SystemExit('%(prog)s: error: sheet is empty. For program \ 846 | usage, see "%(prog)s --help".' % {'prog': os.path.basename(sys.argv[0])}) 847 | running = dbutil.get_active_info(db, sheet) 848 | if running: 849 | duration = str(timedelta(seconds=running[0])) 850 | status_string = dbutil.get_status_string(db, sheet) 851 | if status_string: 852 | print '%s: %s (%s)' % ( 853 | sheet, 854 | duration, 855 | status_string 856 | ) 857 | elif running: 858 | print '%s: (active)' % sheet 859 | else: 860 | print '%s: (inactive)' % sheet 861 | 862 | 863 | @command('insert a new timesheet entry at a specified time') 864 | def insert(db, args): 865 | try: 866 | start = datetime.strptime(args[0], "%Y-%m-%d %H:%M") 867 | end = datetime.strptime(args[1], "%Y-%m-%d %H:%M") 868 | memo = args[2] 869 | 870 | sql = """INSERT INTO entry (sheet, start_time, end_time, description) 871 | VALUES (?, ?, ?, ?)""" 872 | args = ( 873 | dbutil.get_current_sheet(db), 874 | int(time.mktime(start.timetuple())), 875 | int(time.mktime(end.timetuple())), 876 | memo 877 | ) 878 | db.execute(sql, args) 879 | except (ValueError, IndexError, ) as e: 880 | print "Insert requires three arguments, START END DESCRIPTION. \ 881 | Please use the date format \"YYYY-MM-DD HH:MM\"" 882 | logger.exception(e) 883 | 884 | 885 | @command('change details about a specific entry in the timesheet') 886 | def modify(db, args): 887 | if len(args) < 1: 888 | raise exceptions.CommandError("You must select the ID number of an entry \ 889 | of you'd like to modify. Use 'modify latest' to modify the latest entry of the current sheet.") 890 | if args[0] == "latest": 891 | db.execute(u""" 892 | SELECT id 893 | FROM entry WHERE sheet = ? 894 | ORDER BY id DESC LIMIT 1 895 | """, (dbutil.get_current_sheet(db), )) 896 | row = db.fetchone() 897 | if not row: 898 | raise exceptions.CommandError("No entries for modification found.") 899 | id = row[0] 900 | else: 901 | id = args[0] 902 | db.execute(u""" 903 | SELECT start_time, end_time, description 904 | FROM entry WHERE id = ? 905 | """, (id, )) 906 | row = db.fetchone() 907 | if not row: 908 | raise exceptions.CommandError("The ID you specified does not exist.") 909 | start = datetime.fromtimestamp(row[0]) 910 | try: 911 | end = datetime.fromtimestamp(row[1]) 912 | except TypeError: 913 | end = None 914 | 915 | new_start_date = rawinput_date_format( 916 | "Start Date", 917 | "%Y-%m-%d", 918 | start, 919 | ) 920 | new_start_time = rawinput_date_format( 921 | "Start Time", 922 | "%H:%M", 923 | start 924 | ) 925 | start = datetime( 926 | new_start_date.year, 927 | new_start_date.month, 928 | new_start_date.day, 929 | new_start_time.hour, 930 | new_start_time.minute, 931 | ) 932 | new_end_date = rawinput_date_format( 933 | "End Date", 934 | "%Y-%m-%d", 935 | end, 936 | ) 937 | if new_end_date: 938 | new_end_time = rawinput_date_format( 939 | "End Time", 940 | "%H:%M", 941 | end, 942 | ) 943 | if new_end_date and new_end_time: 944 | end = datetime( 945 | new_end_date.year, 946 | new_end_date.month, 947 | new_end_date.day, 948 | new_end_time.hour, 949 | new_end_time.minute, 950 | ) 951 | description = raw_input("Description (\"%s\"):\t" % ( 952 | row[2] 953 | )) 954 | if not description: 955 | description = row[2] 956 | 957 | sql = """ 958 | UPDATE entry 959 | SET start_time = ?, end_time = ?, description = ? WHERE id = ? 960 | """ 961 | args = ( 962 | int(time.mktime(start.timetuple())), 963 | int(time.mktime(end.timetuple())) if end else None, 964 | description, 965 | id 966 | ) 967 | db.execute(sql, args) 968 | 969 | 970 | @command('get ticket details', read_only=True) 971 | def details(db, args): 972 | ticket_number = args[0] 973 | try: 974 | db.execute(""" 975 | SELECT project, details FROM ticket_details WHERE number = ? 976 | """, (ticket_number, )) 977 | details = db.fetchall()[0] 978 | 979 | print "Project: %s" % details[0] 980 | print "Title: %s" % details[1] 981 | 982 | db.execute(""" 983 | SELECT 984 | SUM( 985 | ROUND( 986 | ( 987 | COALESCE(end_time, strftime('%s', 'now')) 988 | - start_time 989 | ) 990 | / CAST(3600 AS FLOAT) 991 | , 2) 992 | ) AS hours 993 | FROM ticket_details 994 | INNER JOIN entry_details ON 995 | entry_details.ticket_number = ticket_details.number 996 | INNER JOIN entry ON 997 | entry_details.entry_id = entry.id 998 | WHERE ticket_number = ? 999 | """, (ticket_number, )) 1000 | total_hours = db.fetchall()[0][0] 1001 | 1002 | db.execute(""" 1003 | SELECT 1004 | SUM( 1005 | ROUND( 1006 | ( 1007 | COALESCE(end_time, strftime('%s', 'now')) 1008 | - start_time 1009 | ) 1010 | / CAST(3600 AS FLOAT) 1011 | , 2) 1012 | ) AS hours 1013 | FROM ticket_details 1014 | INNER JOIN entry_details ON 1015 | entry_details.ticket_number = ticket_details.number 1016 | INNER JOIN entry ON 1017 | entry_details.entry_id = entry.id 1018 | WHERE billable = 1 AND ticket_number = ? 1019 | """, (ticket_number, )) 1020 | total_billable = db.fetchall()[0][0] 1021 | if not total_billable: 1022 | total_billable = 0 1023 | 1024 | print "Total Hours: %s (%s%% billable)" % ( 1025 | total_hours, 1026 | round(total_billable / total_hours * 100, 2) 1027 | ) 1028 | except IndexError as e: 1029 | print "No information available." 1030 | 1031 | 1032 | @command('get timesheet statistics', locking=False, read_only=True) 1033 | def stats(db, args): 1034 | parser = optparse.OptionParser(usage='''usage: %prog stats''') 1035 | parser.add_option('-s', '--start', dest='start', type='string', 1036 | metavar='DATE', 1037 | default=( 1038 | datetime.now() - timedelta(days=7) 1039 | ).strftime('%Y-%m-%d'), 1040 | help='Show only entries \ 1041 | starting after 00:00 on this date. The date should be of the format \ 1042 | YYYY-MM-DD.') 1043 | parser.add_option('-e', '--end', dest='end', type='string', 1044 | metavar='DATE', 1045 | default=datetime.now().strftime('%Y-%m-%d'), 1046 | help='Show only entries \ 1047 | ending before 00:00 on this date. The date should be of the format \ 1048 | YYYY-MM-DD.') 1049 | opts, args = parser.parse_args(args=args) 1050 | 1051 | start_date = datetime.strptime(opts.start, "%Y-%m-%d") 1052 | end_date = datetime.strptime(opts.end, "%Y-%m-%d") 1053 | 1054 | print "Statistics for %s through %s" % ( 1055 | opts.start, opts.end 1056 | ) 1057 | 1058 | db.execute(""" 1059 | SELECT 1060 | COALESCE(billable, 0), 1061 | SUM( 1062 | ROUND( 1063 | ( 1064 | COALESCE(end_time, strftime('%s', 'now')) 1065 | - start_time 1066 | ) 1067 | / CAST(3600 AS FLOAT) 1068 | , 2) 1069 | ) as hours 1070 | FROM entry 1071 | LEFT JOIN entry_details ON entry_details.entry_id = entry.id 1072 | WHERE 1073 | start_time > STRFTIME('%s', ?, 'utc') 1074 | and ( 1075 | end_time < STRFTIME('%s', ?, 'utc', '1 day') 1076 | or end_time is null 1077 | ) 1078 | AND sheet = 'default' 1079 | GROUP BY billable 1080 | ORDER BY billable 1081 | """, (start_date, end_date)) 1082 | results = db.fetchall() 1083 | 1084 | billable_hours = 0 1085 | total_hours = 0 1086 | for result in results: 1087 | if result[0] == 1: 1088 | billable_hours = billable_hours + result[1] 1089 | total_hours = total_hours + result[1] 1090 | 1091 | print "Total Hours: %s (%s%% billable)" % ( 1092 | round(total_hours, 2), 1093 | round(billable_hours / total_hours * 100, 2) 1094 | ) 1095 | 1096 | db.execute(""" 1097 | SELECT 1098 | project, 1099 | SUM( 1100 | ROUND( 1101 | (COALESCE(end_time, strftime('%s', 'now')) - start_time) 1102 | / CAST(3600 AS FLOAT), 2) 1103 | ) as hours 1104 | FROM entry_details 1105 | INNER JOIN entry ON entry_details.entry_id = entry.id 1106 | LEFT JOIN ticket_details ON 1107 | ticket_details.number = entry_details.ticket_number 1108 | WHERE start_time > STRFTIME('%s', ?, 'utc') 1109 | and ( 1110 | end_time < STRFTIME('%s', ?, 'utc', '1 day') 1111 | OR end_time is null 1112 | ) 1113 | AND sheet = 'default' 1114 | GROUP BY project 1115 | ORDER BY hours DESC 1116 | """, 1117 | (start_date, end_date) 1118 | ) 1119 | rows = db.fetchall() 1120 | 1121 | print "\nProject time allocations" 1122 | for row in rows: 1123 | print "%s%%\t%s\t%s" % ( 1124 | round((row[1] / total_hours) * 100, 2), 1125 | row[1], 1126 | row[0] 1127 | ) 1128 | 1129 | db.execute(""" 1130 | SELECT 1131 | details, 1132 | number, 1133 | SUM( 1134 | ROUND( 1135 | (COALESCE(end_time, strftime('%s', 'now')) - start_time) 1136 | / CAST(3600 AS FLOAT), 2) 1137 | ) as hours 1138 | FROM entry_details 1139 | INNER JOIN entry ON entry_details.entry_id = entry.id 1140 | INNER JOIN ticket_details ON 1141 | ticket_details.number = entry_details.ticket_number 1142 | WHERE start_time > STRFTIME('%s', ?, 'utc') 1143 | and ( 1144 | end_time < STRFTIME('%s', ?, 'utc', '1 day') 1145 | OR end_time is null 1146 | ) 1147 | AND sheet = 'default' 1148 | GROUP BY details, number 1149 | ORDER BY hours DESC 1150 | LIMIT 10 1151 | """, 1152 | (start_date, end_date) 1153 | ) 1154 | rows = db.fetchall() 1155 | 1156 | print "\nBiggest Tickets" 1157 | for row in rows: 1158 | print "%s%%\t%s\t%s\t%s" % ( 1159 | round((row[2] / total_hours) * 100, 2), 1160 | row[2], 1161 | row[1], 1162 | row[0] 1163 | ) 1164 | 1165 | 1166 | @command('display timesheet, by default the current one', 1167 | aliases=('export', 'format', 'show'), read_only=True) 1168 | def display(db, args): 1169 | # arguments 1170 | parser = optparse.OptionParser(usage='''usage: %prog display [TIMESHEET] 1171 | 1172 | Display the data from a timesheet in the range of dates specified, either 1173 | in the normal timebook fashion (using --format=plain) or as 1174 | comma-separated value format spreadsheet (using --format=csv), which 1175 | ignores the final entry if active. 1176 | 1177 | If a specific timesheet is given, display the same information for that 1178 | timesheet instead.''') 1179 | parser.add_option('-s', '--start', dest='start', type='string', 1180 | metavar='DATE', help='Show only entries \ 1181 | starting after 00:00 on this date. The date should be of the format \ 1182 | YYYY-MM-DD.') 1183 | parser.add_option('-e', '--end', dest='end', type='string', 1184 | metavar='DATE', help='Show only entries \ 1185 | ending before 00:00 on this date. The date should be of the format \ 1186 | YYYY-MM-DD.') 1187 | parser.add_option('-f', '--format', dest='format', type='string', 1188 | default='plain', 1189 | help="Select whether to output in the normal timebook \ 1190 | style (--format=plain) or csv --format=csv or eu timesheet csv --format=eu") 1191 | parser.add_option('-i', '--show-ids', dest='show_ids', 1192 | action='store_true', default=False) 1193 | parser.add_option('--summary', dest='summary', 1194 | action='store_true', default=False) 1195 | parser.add_option('-m', '--month', dest='month', type='int', 1196 | default=0, help='Month to export int[1 .. 12]') 1197 | opts, args = parser.parse_args(args=args) 1198 | 1199 | # grab correct sheet 1200 | if args: 1201 | sheet = cmdutil.complete(dbutil.get_sheet_names(db), args[0], 1202 | 'timesheet') 1203 | else: 1204 | sheet = dbutil.get_current_sheet(db) 1205 | 1206 | # calculate "where" 1207 | where = '' 1208 | if opts.month > 0: 1209 | # if month option is used, overwrite start and end date 1210 | y = datetime.now().year 1211 | opts.start = "%d-%d-01" % (y, opts.month) 1212 | opts.end = "%d-%d-%d" % (y, opts.month, calendar.monthrange(y, opts.month)[1]) 1213 | 1214 | if opts.start is not None: 1215 | start = cmdutil.parse_date_time(opts.start) 1216 | where += ' and start_time >= %s' % start 1217 | else: 1218 | where += ''' and start_time > 1219 | STRFTIME(\'%s\', \'now\', \'-6 days\', \'start of day\') 1220 | ''' 1221 | if opts.end is not None: 1222 | end = cmdutil.parse_date_time(opts.end) 1223 | where += ' and end_time <= %s' % end 1224 | if opts.format == 'plain': 1225 | format_timebook( 1226 | db, sheet, where, show_ids=opts.show_ids, summary=opts.summary 1227 | ) 1228 | elif opts.format == 'csv': 1229 | format_csv(db, sheet, where, show_ids=opts.show_ids) 1230 | elif opts.format == 'eu': 1231 | format_eu( 1232 | db, sheet, where, show_ids=opts.show_ids, 1233 | sdate=datetime.strptime(opts.start, '%Y-%m-%d'), edate=datetime.strptime(opts.end, '%Y-%m-%d')) 1234 | else: 1235 | raise SystemExit('Invalid format: %s' % opts.format) 1236 | 1237 | 1238 | def format_eu(db, sheet, where, show_ids=False, sdate=None, edate=None): 1239 | """ 1240 | Attention: Assumes that all queried data is from one month! 1241 | """ 1242 | if sdate is None or edate is None: 1243 | raise SystemExit("Please specify start and end or month for export.") 1244 | import csv 1245 | writer = csv.writer(sys.stdout, delimiter=';', dialect='excel') 1246 | 1247 | def daterange(start_date, end_date): 1248 | for n in range(int((end_date - start_date).days) + 1): 1249 | yield start_date + timedelta(n) 1250 | 1251 | def round_working_hours(t): 1252 | """ 1253 | Rounding with half hour steps. 1254 | """ 1255 | return round(t*2/60.0/60.0, 0)/2 1256 | 1257 | day_total = None 1258 | db.execute(u''' 1259 | select 1260 | id, 1261 | date(e.start_time, 'unixepoch', 'localtime') as day, 1262 | ifnull(sum(ifnull(e.end_time, strftime('%%s', 'now')) - 1263 | e.start_time), 0) as day_total, 1264 | ifnull(e.description, '') as description 1265 | from 1266 | entry e 1267 | where 1268 | e.sheet = ?%s 1269 | group by 1270 | id 1271 | order by 1272 | day asc; 1273 | ''' % where, (sheet,)) 1274 | rows = db.fetchall() 1275 | export_data = {} 1276 | description_list = [] 1277 | 1278 | for r in rows: 1279 | cell_id = r[0] 1280 | cell_date = r[1] 1281 | cell_workhours = r[2] 1282 | cell_description = r[3] 1283 | 1284 | # get metadata (especially the workpackage (WP) column) 1285 | ticket_metadata = dbutil.get_entry_meta(db, r[0]) 1286 | try: 1287 | cell_wp = int(ticket_metadata["wp"]) 1288 | except: 1289 | cell_wp = 0 1290 | 1291 | # create a simple list with all descriptions 1292 | if cell_description not in description_list: 1293 | description_list.append(cell_description) 1294 | 1295 | # create export data as table: rows=WP columns=data 1296 | if cell_wp not in export_data: 1297 | export_data[cell_wp] = {} 1298 | if cell_date not in export_data[cell_wp]: 1299 | export_data[cell_wp][cell_date] = 0.0 1300 | export_data[cell_wp][cell_date] += cell_workhours 1301 | 1302 | # csv export 1303 | # header 1304 | dates = [d.strftime("%Y-%m-%d") for d in daterange(sdate, edate)] 1305 | writer.writerow(["WP"] + dates) 1306 | 1307 | for wp in range(1, 8): 1308 | if wp in export_data: 1309 | columns = export_data[wp] 1310 | else: 1311 | columns = {} 1312 | curr_row = [wp] 1313 | for d in dates: 1314 | if d in columns: 1315 | hours = round_working_hours(columns[d]) 1316 | if hours > 0: 1317 | curr_row.append(str(hours).replace(".", ",")) 1318 | else: 1319 | curr_row.append("") 1320 | else: 1321 | curr_row.append("") 1322 | writer.writerow(curr_row) 1323 | writer.writerow([]) 1324 | writer.writerow([]) 1325 | writer.writerow(["Descriptions:", ", ".join(description_list)]) 1326 | 1327 | 1328 | def format_csv(db, sheet, where, show_ids=False): 1329 | import csv 1330 | 1331 | writer = csv.writer(sys.stdout) 1332 | writer.writerow(('Start', 'End', 'Length', 'Description')) 1333 | db.execute(u''' 1334 | select 1335 | start_time, 1336 | end_time, 1337 | ifnull(end_time, strftime('%%s', 'now')) - 1338 | start_time, 1339 | description, 1340 | id 1341 | from 1342 | entry 1343 | where 1344 | sheet = ? and 1345 | end_time is not null%s 1346 | ''' % where, (sheet,)) 1347 | format = lambda t: datetime.fromtimestamp(t).strftime( 1348 | '%m/%d/%Y %H:%M:%S') 1349 | rows = db.fetchall() 1350 | if(show_ids): 1351 | writer.writerows( 1352 | map( 1353 | lambda row: ( 1354 | format(row[0]), 1355 | format(row[1]), 1356 | row[2], 1357 | row[3], 1358 | row[4] 1359 | ), 1360 | rows 1361 | ) 1362 | ) 1363 | else: 1364 | writer.writerows( 1365 | map( 1366 | lambda row: ( 1367 | format(row[0]), 1368 | format(row[1]), 1369 | row[2], 1370 | row[3] 1371 | ), 1372 | rows 1373 | ) 1374 | ) 1375 | total_formula = '=SUM(C2:C%d)/3600' % (len(rows) + 1) 1376 | writer.writerow(('Total', '', total_formula, '')) 1377 | 1378 | 1379 | def format_timebook(db, sheet, where, show_ids=False, summary=False): 1380 | db.execute(u''' 1381 | select count(*) > 0 from entry where sheet = ?%s 1382 | ''' % where, (sheet,)) 1383 | if not db.fetchone()[0]: 1384 | print '(empty)' 1385 | return 1386 | 1387 | displ_time = lambda t: time.strftime('%H:%M:%S', time.localtime(t)) 1388 | displ_date = lambda t: time.strftime('%b %d, %Y', 1389 | time.localtime(t)) 1390 | 1391 | def displ_total(t): 1392 | if not summary: 1393 | return cmdutil.timedelta_hms_display(timedelta(seconds=t)) 1394 | return str(round(t/60.0/60.0, 2)) 1395 | 1396 | last_day = None 1397 | day_total = None 1398 | db.execute(u''' 1399 | select 1400 | date(e.start_time, 'unixepoch', 'localtime') as day, 1401 | ifnull(sum(ifnull(e.end_time, strftime('%%s', 'now')) - 1402 | e.start_time), 0) as day_total 1403 | from 1404 | entry e 1405 | where 1406 | e.sheet = ?%s 1407 | group by 1408 | day 1409 | order by 1410 | day asc; 1411 | ''' % where, (sheet,)) 1412 | days = db.fetchall() 1413 | days_iter = iter(days) 1414 | 1415 | if summary: 1416 | db.execute(u''' 1417 | select 1418 | date(e.start_time, 'unixepoch', 'localtime') as day, 1419 | min(e.start_time) as start, 1420 | max(e.end_time) as end, 1421 | sum(ifnull(e.end_time, strftime('%%s', 'now')) - e.start_time) as 1422 | duration, 1423 | ifnull(e.description, '') as description, 1424 | min(id) 1425 | from 1426 | entry e 1427 | where 1428 | e.sheet = ?%s 1429 | group by 1430 | date(e.start_time, 'unixepoch', 'localtime'), 1431 | ifnull(e.description, '') 1432 | order by 1433 | day asc; 1434 | ''' % where, (sheet,)) 1435 | else: 1436 | db.execute(u''' 1437 | select 1438 | date(e.start_time, 'unixepoch', 'localtime') as day, 1439 | e.start_time as start, 1440 | e.end_time as end, 1441 | ifnull(e.end_time, strftime('%%s', 'now')) - e.start_time as 1442 | duration, 1443 | ifnull(e.description, '') as description, 1444 | id 1445 | from 1446 | entry e 1447 | where 1448 | e.sheet = ?%s 1449 | order by 1450 | day asc; 1451 | ''' % where, (sheet,)) 1452 | entries = db.fetchall() 1453 | 1454 | # Get list of total metadata keys 1455 | db.execute(u''' 1456 | select 1457 | distinct key, count(entry_id) 1458 | from entry_meta 1459 | inner join entry 1460 | on entry.id = entry_meta.entry_id 1461 | where 1462 | entry.sheet = ? 1463 | %s 1464 | group by key 1465 | order by count(entry_id) desc 1466 | ''' % where, (sheet, )) 1467 | metadata_keys = db.fetchall() 1468 | extra_count = len(metadata_keys) 1469 | if show_ids: 1470 | extra_count = extra_count + 1 1471 | 1472 | table = [] 1473 | table_header = ['Day', 'Start End', 'Duration'] 1474 | for key in metadata_keys: 1475 | table_header.append( 1476 | key[0].title().replace('_', ' ') 1477 | ) 1478 | table_header.append('Notes') 1479 | if show_ids: 1480 | table_header.append('ID') 1481 | table.append(table_header) 1482 | for i, (day, start, end, duration, description, id) in \ 1483 | enumerate(entries): 1484 | id = str(id) 1485 | date = displ_date(start) 1486 | diff = displ_total(duration) 1487 | if end is None: 1488 | trange = '%s -' % displ_time(start) 1489 | else: 1490 | trange = '%s - %s' % (displ_time(start), displ_time(end)) 1491 | if last_day == day: 1492 | # If this row doesn't represent the first entry of the 1493 | # day, don't display anything in the day column. 1494 | row = [''] 1495 | else: 1496 | if day_total: 1497 | table.append(['', '', displ_total(day_total), ''] 1498 | + [''] * extra_count 1499 | ) 1500 | row = [date] 1501 | cur_day, day_total = days_iter.next() 1502 | row.extend([ 1503 | trange, diff 1504 | ]) 1505 | ticket_metadata = dbutil.get_entry_meta(db, id) 1506 | for meta in metadata_keys: 1507 | key = meta[0] 1508 | row.append( 1509 | ticket_metadata[key] if ( 1510 | key in ticket_metadata.keys() 1511 | ) else '' 1512 | ) 1513 | row.append(description) 1514 | if show_ids: 1515 | row.append(id) 1516 | table.append(row) 1517 | last_day = day 1518 | 1519 | db.execute(u''' 1520 | select 1521 | ifnull(sum(ifnull(e.end_time, strftime('%%s', 'now')) - 1522 | e.start_time), 0) as total 1523 | from 1524 | entry e 1525 | where 1526 | e.sheet = ?%s; 1527 | ''' % where, (sheet,)) 1528 | total = displ_total(db.fetchone()[0]) 1529 | table += [['', '', displ_total(day_total), ''] + [''] * extra_count, 1530 | ['Total', '', total, '',] + [''] * extra_count] 1531 | cmdutil.pprint_table(table, footer_row=True) 1532 | -------------------------------------------------------------------------------- /timebook/config.py: -------------------------------------------------------------------------------- 1 | # config.py 2 | # 3 | # Copyright (c) 2008-2009 Trevor Caira, 2011-2012 Adam Coddington 4 | # 5 | # Permission is hereby granted, free of charge, to any person obtaining 6 | # a copy of this software and associated documentation files (the 7 | # "Software"), to deal in the Software without restriction, including 8 | # without limitation the rights to use, copy, modify, merge, publish, 9 | # distribute, sublicense, and/or sell copies of the Software, and to 10 | # permit persons to whom the Software is furnished to do so, subject to 11 | # the following conditions: 12 | # 13 | # The above copyright notice and this permission notice shall be 14 | # included in all copies or substantial portions of the Software. 15 | # 16 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | # IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 20 | # CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 21 | # TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 22 | # SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | from ConfigParser import SafeConfigParser 25 | import os 26 | 27 | 28 | class ConfigParser(SafeConfigParser): 29 | def __getitem__(self, name): 30 | return dict(self.items(name)) 31 | 32 | def get_with_default(self, section, name, default): 33 | if self.has_option(section, name): 34 | return self.get(section, name) 35 | return default 36 | 37 | 38 | def subdirs(path): 39 | path = os.path.abspath(path) 40 | last = path.find(os.path.sep) 41 | while True: 42 | if last == -1: 43 | break 44 | yield path[:last + 1] 45 | last = path.find(os.path.sep, last + 1) 46 | 47 | 48 | def parse_config(filename): 49 | config = ConfigParser() 50 | if not os.path.exists(os.path.dirname(filename)): 51 | for d in subdirs(filename): 52 | if os.path.exists(d): 53 | continue 54 | else: 55 | os.mkdir(d) 56 | if not os.path.exists(filename): 57 | f = open(filename, 'w') 58 | try: 59 | f.write('# timebook configuration file') 60 | finally: 61 | f.close() 62 | f = open(filename) 63 | try: 64 | config.readfp(f) 65 | finally: 66 | f.close() 67 | return config 68 | -------------------------------------------------------------------------------- /timebook/db.py: -------------------------------------------------------------------------------- 1 | # db.py 2 | # 3 | # Copyright (c) 2008-2009 Trevor Caira, 2011-2012 Adam Coddington 4 | # 5 | # Permission is hereby granted, free of charge, to any person obtaining 6 | # a copy of this software and associated documentation files (the 7 | # "Software"), to deal in the Software without restriction, including 8 | # without limitation the rights to use, copy, modify, merge, publish, 9 | # distribute, sublicense, and/or sell copies of the Software, and to 10 | # permit persons to whom the Software is furnished to do so, subject to 11 | # the following conditions: 12 | # 13 | # The above copyright notice and this permission notice shall be 14 | # included in all copies or substantial portions of the Software. 15 | # 16 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | # IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 20 | # CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 21 | # TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 22 | # SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | import sqlite3 25 | 26 | from timebook.migrations import MigrationManager 27 | 28 | 29 | class Database(object): 30 | def __init__(self, path, config): 31 | self.config = config 32 | self.path = path 33 | self.connection = sqlite3.connect(path, isolation_level=None) 34 | self.cursor = self.connection.cursor() 35 | for attr in ('execute', 'executescript', 'fetchone', 'fetchall'): 36 | setattr(self, attr, getattr(self.cursor, attr)) 37 | self._initialize_db() 38 | 39 | @property 40 | def db_version(self): 41 | try: 42 | self.execute(''' 43 | SELECT value FROM meta WHERE key = 'db_version' 44 | ''') 45 | return int(self.fetchone()[0]) 46 | except: 47 | return 0 48 | 49 | def _initialize_db(self): 50 | manager = MigrationManager(self) 51 | manager.upgrade() 52 | -------------------------------------------------------------------------------- /timebook/dbutil.py: -------------------------------------------------------------------------------- 1 | # dbutil.py 2 | # 3 | # Copyright (c) 2008-2009 Trevor Caira, 2011-2012 Adam Coddington 4 | # 5 | # Permission is hereby granted, free of charge, to any person obtaining 6 | # a copy of this software and associated documentation files (the 7 | # "Software"), to deal in the Software without restriction, including 8 | # without limitation the rights to use, copy, modify, merge, publish, 9 | # distribute, sublicense, and/or sell copies of the Software, and to 10 | # permit persons to whom the Software is furnished to do so, subject to 11 | # the following conditions: 12 | # 13 | # The above copyright notice and this permission notice shall be 14 | # included in all copies or substantial portions of the Software. 15 | # 16 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | # IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 20 | # CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 21 | # TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 22 | # SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | import datetime 25 | import re 26 | import time 27 | 28 | from timebook.chiliproject import ChiliprojectConnector 29 | 30 | 31 | def get_current_sheet(db): 32 | db.execute(u''' 33 | select 34 | value 35 | from 36 | meta 37 | where 38 | key = 'current_sheet' 39 | ''') 40 | return db.fetchone()[0] 41 | 42 | 43 | def get_sheet_names(db): 44 | db.execute(u''' 45 | select 46 | distinct sheet 47 | from 48 | entry 49 | ''') 50 | return tuple(r[0] for r in db.fetchall()) 51 | 52 | 53 | def get_active_info(db, sheet): 54 | db.execute(u''' 55 | select 56 | strftime('%s', 'now') - entry.start_time, 57 | entry.description, 58 | id 59 | from 60 | entry 61 | where 62 | entry.sheet = ? and 63 | entry.end_time is null 64 | ''', (sheet,)) 65 | return db.fetchone() 66 | 67 | 68 | def get_current_active_info(db): 69 | db.execute(u''' 70 | select 71 | entry.id, 72 | strftime('%s', 'now') - entry.start_time 73 | from 74 | entry 75 | inner join 76 | meta 77 | on 78 | meta.key = 'current_sheet' and 79 | meta.value = entry.sheet 80 | where 81 | entry.end_time is null 82 | ''') 83 | return db.fetchone() 84 | 85 | 86 | def get_current_start_time(db): 87 | db.execute(u''' 88 | select 89 | entry.id, 90 | entry.start_time 91 | from 92 | entry 93 | inner join 94 | meta 95 | on 96 | meta.key = 'current_sheet' and 97 | meta.value = entry.sheet 98 | where 99 | entry.end_time is null 100 | ''') 101 | return db.fetchone() 102 | 103 | 104 | def get_entry_count(db, sheet): 105 | db.execute(u''' 106 | select 107 | count(*) 108 | from 109 | entry e 110 | where 111 | sheet = ? 112 | ''', (sheet,)) 113 | return db.fetchone()[0] 114 | 115 | 116 | def get_most_recent_clockout(db, sheet): 117 | db.execute(u''' 118 | select 119 | id, start_time, end_time, description 120 | from 121 | entry 122 | where 123 | sheet = ? 124 | order by 125 | -end_time 126 | ''', (sheet,)) 127 | return db.fetchone() 128 | 129 | 130 | def update_entry_meta(db, id, meta): 131 | existing_meta = get_entry_meta(db, id) 132 | for key, value in meta.items(): 133 | if key in existing_meta.keys() and value != existing_meta[key]: 134 | db.execute(u''' 135 | update 136 | entry_meta 137 | set 138 | value = ? 139 | where 140 | key = ? 141 | and 142 | entry_id = ? 143 | ''', ( 144 | value, 145 | key, 146 | id, 147 | ) 148 | ) 149 | else: 150 | db.execute(u''' 151 | insert into 152 | entry_meta 153 | (key, value, entry_id) 154 | values 155 | (?, ?, ?) 156 | ''', ( 157 | key, 158 | value, 159 | id 160 | ) 161 | ) 162 | 163 | 164 | def get_entry_meta(db, id): 165 | meta = {} 166 | db.execute(u''' 167 | select 168 | key, value 169 | from 170 | entry_meta 171 | where 172 | entry_id = ? 173 | order by 174 | key 175 | ''', (id, )) 176 | for row in db.fetchall(): 177 | key = row[0] 178 | value = row[1] 179 | meta[key] = value 180 | return meta 181 | 182 | 183 | def date_is_vacation(db, year, month, day): 184 | db.execute(u''' 185 | select 186 | count(*) 187 | from 188 | vacation 189 | where year = ? 190 | and month = ? 191 | and day = ? 192 | ''', (year, month, day,)) 193 | if db.fetchone()[0] > 0: 194 | return True 195 | return False 196 | 197 | 198 | def date_is_holiday(db, year, month, day): 199 | db.execute(u''' 200 | select 201 | count(*) 202 | from 203 | holidays 204 | where year = ? 205 | and month = ? 206 | and day = ? 207 | ''', (year, month, day,)) 208 | if db.fetchone()[0] > 0: 209 | return True 210 | return False 211 | 212 | 213 | def date_is_unpaid(db, year, month, day): 214 | db.execute(u''' 215 | select 216 | count(*) 217 | from 218 | unpaid 219 | where year = ? 220 | and month = ? 221 | and day = ? 222 | ''', (year, month, day,)) 223 | if db.fetchone()[0] > 0: 224 | return True 225 | return False 226 | 227 | 228 | def date_is_untracked(db, year, month, day): 229 | untracked_checks = [ 230 | date_is_vacation, 231 | date_is_holiday, 232 | date_is_unpaid, 233 | ] 234 | for check in untracked_checks: 235 | if check(db, year, month, day): 236 | return True 237 | return False 238 | 239 | 240 | class TimesheetRow(object): 241 | TICKET_MATCHER = re.compile( 242 | r"^(?:(\d{4,6})(?:[^0-9]|$)+|.*#(\d{4,6})(?:[^0-9]|$)+)" 243 | ) 244 | TICKET_URL = "http://chili.parthenonsoftware.com/issues/%s/" 245 | 246 | def __init__(self): 247 | self.lookup_handler = False 248 | self.db = False 249 | self.meta = {} 250 | 251 | @staticmethod 252 | def from_row(row): 253 | t = TimesheetRow() 254 | t.id = row[0] 255 | t.start_time_epoch = row[1] 256 | t.end_time_epoch = row[2] 257 | t.description = row[3] 258 | t.hours = row[4] 259 | return t 260 | 261 | def set_meta(self, meta): 262 | self.meta = meta 263 | 264 | def meta_key_has_value(self, key): 265 | if key in self.meta.keys() and self.meta[key]: 266 | return True 267 | return False 268 | 269 | def set_lookup_handler(self, handler): 270 | self.lookup_handler = handler 271 | 272 | @property 273 | def is_active(self): 274 | if not self.end_time_epoch: 275 | return True 276 | return False 277 | 278 | @property 279 | def chili_detail(self): 280 | if self.lookup_handler: 281 | if self.ticket_number: 282 | return self.lookup_handler.get_description_for_ticket( 283 | self.ticket_number 284 | ) 285 | 286 | @property 287 | def start_time(self): 288 | return datetime.datetime.fromtimestamp(float(self.start_time_epoch)) 289 | 290 | @property 291 | def end_time(self): 292 | if self.end_time_epoch: 293 | return datetime.datetime.fromtimestamp(float(self.end_time_epoch)) 294 | 295 | @property 296 | def is_ticket(self): 297 | if self.meta_key_has_value('ticket_number'): 298 | return True 299 | elif self.description and self.ticket_number: 300 | return True 301 | return False 302 | 303 | @property 304 | def ticket_number(self): 305 | if self.meta_key_has_value('ticket_number'): 306 | return self.meta['ticket_number'] 307 | elif self.description: 308 | matches = self.TICKET_MATCHER.match(self.description) 309 | if matches: 310 | for match in matches.groups(): 311 | if match: 312 | return match 313 | return None 314 | 315 | @property 316 | def timesheet_description(self): 317 | if self.description: 318 | return self.description 319 | else: 320 | return '' 321 | 322 | @property 323 | def is_billable(self): 324 | if self.meta_key_has_value('billable'): 325 | return True if self.meta['billable'] == 'yes' else False 326 | elif self.description: 327 | ticket_match = re.match(r"^(\d{4,6})$", self.description) 328 | force_billable_search = re.search( 329 | r"\(Billable\)", 330 | self.description, 331 | re.IGNORECASE 332 | ) 333 | if ticket_match: 334 | return True 335 | if force_billable_search: 336 | return True 337 | return False 338 | 339 | @property 340 | def ticket_url(self): 341 | return self.TICKET_URL % self.ticket_number 342 | 343 | @property 344 | def end_time_or_now(self): 345 | return datetime.datetime.fromtimestamp( 346 | float(self.end_time_epoch_or_now) 347 | ) 348 | 349 | @property 350 | def end_time_epoch_or_now(self): 351 | if self.end_time_epoch: 352 | return self.end_time_epoch 353 | else: 354 | return time.time() 355 | 356 | @property 357 | def total_hours(self): 358 | return float(self.end_time_epoch_or_now - self.start_time_epoch) / 3600 359 | 360 | def __str__(self): 361 | return """%s - %s; %s""" % ( 362 | self.start_time, 363 | self.end_time_or_now, 364 | self.description if not self.ticket_number else "%s%s" % ( 365 | self.ticket_number, 366 | " (" + self.chili_detail + ")" 367 | if self.chili_detail else "" 368 | ), 369 | ) 370 | 371 | CHILIPROJECT_LOOKUP = None 372 | 373 | 374 | def timesheet_row_factory(cursor, row): 375 | global CHILIPROJECT_LOOKUP 376 | if not CHILIPROJECT_LOOKUP: 377 | CHILIPROJECT_LOOKUP = ChiliprojectConnector() 378 | ts = TimesheetRow.from_row(row) 379 | ts.set_lookup_handler(CHILIPROJECT_LOOKUP) 380 | return ts 381 | 382 | 383 | def dict_factory(cursor, row): 384 | d = {} 385 | for idx, col in enumerate(cursor.description): 386 | d[col[0]] = row[idx] 387 | return d 388 | 389 | 390 | def get_status_string(db, sheet, exclude=None): 391 | if exclude is None: 392 | exclude = [] 393 | running = get_active_info(db, sheet) 394 | 395 | if running is None: 396 | return None 397 | 398 | meta = get_entry_meta(db, running[2]) 399 | meta_string = ', '.join( 400 | ['%s: %s' % (k, v) for k, v in meta.items() if k not in exclude] 401 | ) 402 | description = running[1] 403 | 404 | details_parts = [] 405 | if description: 406 | details_parts.append(description) 407 | if meta_string: 408 | details_parts.append(meta_string) 409 | 410 | return '; '.join(details_parts) 411 | 412 | active = '%s' % duration 413 | 414 | if details_parts: 415 | active = '%s (%s)' % ( 416 | active, 417 | '; '.join(details_parts) 418 | ) 419 | return active 420 | -------------------------------------------------------------------------------- /timebook/exceptions.py: -------------------------------------------------------------------------------- 1 | # exceptions.py 2 | # 3 | # Copyright (c) 2012 Adam Coddington 4 | # 5 | # Permission is hereby granted, free of charge, to any person obtaining 6 | # a copy of this software and associated documentation files (the 7 | # "Software"), to deal in the Software without restriction, including 8 | # without limitation the rights to use, copy, modify, merge, publish, 9 | # distribute, sublicense, and/or sell copies of the Software, and to 10 | # permit persons to whom the Software is furnished to do so, subject to 11 | # the following conditions: 12 | # 13 | # The above copyright notice and this permission notice shall be 14 | # included in all copies or substantial portions of the Software. 15 | # 16 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | # IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 20 | # CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 21 | # TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 22 | # SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | 25 | class ReportingException(Exception): 26 | pass 27 | 28 | 29 | class HookException(Exception): 30 | pass 31 | 32 | 33 | class PreHookException(Exception): 34 | pass 35 | 36 | 37 | class PostHookException(Exception): 38 | pass 39 | 40 | 41 | class CommandError(Exception): 42 | pass 43 | 44 | 45 | class AuthenticationError(Exception): 46 | pass 47 | -------------------------------------------------------------------------------- /timebook/migrations/0001InitialMigration.py: -------------------------------------------------------------------------------- 1 | from timebook.migrations import Migration 2 | 3 | 4 | class InitialMigration(Migration): 5 | def run(self): 6 | self.db.executescript(u''' 7 | begin; 8 | create table if not exists meta ( 9 | key varchar(16) primary key not null, 10 | value varchar(32) not null 11 | ); 12 | create table if not exists entry ( 13 | id integer primary key not null, 14 | sheet varchar(32) not null, 15 | start_time integer not null, 16 | end_time integer, 17 | description varchar(64), 18 | extra blob 19 | ); 20 | create table if not exists entry_details ( 21 | entry_id integer primary key not null, 22 | ticket_number integer default null, 23 | billable integer default 0 24 | ); 25 | CREATE TABLE if not exists holidays ( 26 | year integer default null, 27 | month integer, 28 | day integer 29 | ); 30 | CREATE TABLE if not exists unpaid ( 31 | year integer default null, 32 | month integer, 33 | day integer 34 | ); 35 | CREATE TABLE if not exists vacation ( 36 | year integer default null, 37 | month integer, 38 | day integer 39 | ); 40 | CREATE TABLE if not exists ticket_details ( 41 | number integer, 42 | project string, 43 | details string 44 | ); 45 | create index if not exists entry_sheet on entry (sheet); 46 | create index if not exists entry_start_time on entry (start_time); 47 | create index if not exists entry_end_time on entry (end_time); 48 | commit; 49 | ''') 50 | self.db.execute(u''' 51 | select 52 | count(*) 53 | from 54 | meta 55 | where 56 | key = 'current_sheet' 57 | ''') 58 | count = self.db.fetchone()[0] 59 | if count == 0: 60 | self.db.execute(u''' 61 | insert into meta ( 62 | key, value 63 | ) values ( 64 | 'current_sheet', 'default' 65 | )''') 66 | self.db.execute(u''' 67 | select 68 | count(*) 69 | from 70 | meta 71 | where 72 | key = 'db_version' 73 | ''') 74 | count = self.db.fetchone()[0] 75 | if count == 0: 76 | self.db.execute(u''' 77 | insert into meta ( 78 | key, value 79 | ) values ( 80 | 'db_version', 81 | 0 82 | ); 83 | ''') 84 | -------------------------------------------------------------------------------- /timebook/migrations/0002TicketMetadata.py: -------------------------------------------------------------------------------- 1 | from timebook.migrations import Migration 2 | 3 | 4 | class TicketMetadataMigration(Migration): 5 | def run(self): 6 | self.db.executescript(u''' 7 | begin; 8 | create table if not exists entry_meta( 9 | entry_id integer not null, 10 | key varchar(16) not null, 11 | value varchar(256) not null 12 | ); 13 | commit; 14 | ''') 15 | -------------------------------------------------------------------------------- /timebook/migrations/0003AddHourAdjustments.py: -------------------------------------------------------------------------------- 1 | from timebook.migrations import Migration 2 | 3 | 4 | class HourAdjustmentsMigration(Migration): 5 | def run(self): 6 | self.db.executescript(u''' 7 | CREATE TABLE if not exists adjustments ( 8 | timestamp integer not null, 9 | adjustment float, 10 | description text 11 | ); 12 | ''') 13 | -------------------------------------------------------------------------------- /timebook/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | import imp 2 | import inspect 3 | import os.path 4 | import re 5 | 6 | 7 | class MigrationManager(object): 8 | def __init__(self, db): 9 | self.db = db 10 | 11 | def _is_unapplied(self, migration_info): 12 | if self.db.db_version < migration_info['number']: 13 | return True 14 | return False 15 | 16 | def _get_migration_classes(self, migration_number, mod_path): 17 | migrations = [] 18 | 19 | mod = imp.load_source( 20 | 'timebook.migrations.migration%s' % migration_number, 21 | os.path.join( 22 | os.path.dirname( 23 | __file__ 24 | ), 25 | mod_path, 26 | ) 27 | ) 28 | members = inspect.getmembers(mod) 29 | for name, member in members: 30 | if ( 31 | inspect.isclass(member) 32 | and issubclass(member, Migration) 33 | and member.__name__ != Migration.__name__ 34 | ): 35 | migrations.append(member) 36 | return migrations 37 | 38 | def _find_migration_modules(self): 39 | migration_modules = [] 40 | for mod_path in os.listdir(os.path.dirname( 41 | __file__ 42 | )): 43 | migration_details = re.match(r'^(\d+)(\D*)\.py$', mod_path) 44 | if migration_details: 45 | try: 46 | number = int(migration_details.groups()[0]) 47 | name = migration_details.groups()[1] 48 | migrations = self._get_migration_classes( 49 | number, 50 | mod_path 51 | ) 52 | if migrations: 53 | migration_modules.append( 54 | { 55 | 'name': name, 56 | 'number': number, 57 | 'migrations': migrations 58 | } 59 | ) 60 | except ValueError: 61 | pass 62 | migration_modules = sorted( 63 | migration_modules, 64 | key=lambda k: k['number'] 65 | ) 66 | return migration_modules 67 | 68 | def _apply_migration(self, module): 69 | for migration_class in module['migrations']: 70 | migration = migration_class(self.db) 71 | migration.run() 72 | 73 | self._register_migration(module) 74 | 75 | def _register_migration(self, module): 76 | self.db.execute(''' 77 | UPDATE meta SET value = ? WHERE key = 'db_version'; 78 | ''', (module['number'], )) 79 | 80 | def upgrade(self): 81 | modules = self._find_migration_modules() 82 | for module in modules: 83 | if self._is_unapplied(module): 84 | self._apply_migration(module) 85 | 86 | 87 | class MigrationException(Exception): 88 | pass 89 | 90 | 91 | class Migration(object): 92 | def __init__(self, db): 93 | self.db = db 94 | 95 | def run(self): 96 | raise MigrationException( 97 | "Run method must be declared in each migration." 98 | ) 99 | -------------------------------------------------------------------------------- /timebook/payperiodtypes.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2011-2012 Adam Coddington 2 | # 3 | # Permission is hereby granted, free of charge, to any person obtaining 4 | # a copy of this software and associated documentation files (the 5 | # "Software"), to deal in the Software without restriction, including 6 | # without limitation the rights to use, copy, modify, merge, publish, 7 | # distribute, sublicense, and/or sell copies of the Software, and to 8 | # permit persons to whom the Software is furnished to do so, subject to 9 | # the following conditions: 10 | # 11 | # The above copyright notice and this permission notice shall be 12 | # included in all copies or substantial portions of the Software. 13 | # 14 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 17 | # IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 18 | # CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 19 | # TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 20 | # SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | from datetime import datetime, timedelta 22 | 23 | from dateutil import relativedelta, rrule 24 | 25 | 26 | class PayPeriod(object): 27 | def __init__(self, now): 28 | self.now = now 29 | 30 | @property 31 | def begin_period(self): 32 | raise ValueError() 33 | 34 | @property 35 | def end_period(self): 36 | raise ValueError() 37 | 38 | @property 39 | def weekdays_rule(self): 40 | return rrule.rrule( 41 | rrule.DAILY, 42 | byweekday=( 43 | rrule.MO, rrule.TU, rrule.WE, rrule.TH, rrule.FR, 44 | ), 45 | dtstart=self.begin_period, 46 | ) 47 | 48 | @property 49 | def hours_per_day(self): 50 | return 8 51 | 52 | 53 | class RollingWindowPayPeriod(PayPeriod): 54 | window_size = None 55 | 56 | @property 57 | def begin_period(self): 58 | return self.now - relativedelta.relativedelta( 59 | days=self.window_size, 60 | hour=0, 61 | minute=0, 62 | second=0, 63 | microsecond=0, 64 | ) 65 | 66 | @property 67 | def end_period(self): 68 | return self.now + relativedelta.relativedelta( 69 | days=1, 70 | hour=0, 71 | minute=0, 72 | second=0, 73 | microsecond=0, 74 | ) 75 | 76 | 77 | class Rolling7DayWindow(RollingWindowPayPeriod): 78 | window_size = 7 79 | 80 | 81 | class Rolling30DayWindow(RollingWindowPayPeriod): 82 | window_size = 30 83 | 84 | 85 | class Rolling90DayWindow(RollingWindowPayPeriod): 86 | window_size = 90 87 | 88 | 89 | class RollingAnnualWindow(RollingWindowPayPeriod): 90 | window_size = 365 91 | 92 | 93 | class MonthlyOnSecondToLastFriday(PayPeriod): 94 | @property 95 | def begin_period(self): 96 | return self.now - relativedelta.relativedelta( 97 | day=31, 98 | months=1, 99 | hour=0, 100 | minute=0, 101 | second=0, 102 | microsecond=0, 103 | weekday=rrule.FR(-2) 104 | ) + timedelta(days = 1) 105 | 106 | @property 107 | def end_period(self): 108 | return self.now + relativedelta.relativedelta( 109 | months=0, 110 | day=31, 111 | hour=0, 112 | minute=0, 113 | second=0, 114 | microsecond=0, 115 | weekday=rrule.FR(-2) 116 | ) + timedelta(days=1) 117 | 118 | 119 | class Weekly(PayPeriod): 120 | @property 121 | def begin_period(self): 122 | return self.now - relativedelta.relativedelta( 123 | hour=0, 124 | minute=0, 125 | second=0, 126 | microsecond=0, 127 | weekday=rrule.SU(-1) 128 | ) + timedelta(days = 1) 129 | 130 | @property 131 | def end_period(self): 132 | return self.now + relativedelta.relativedelta( 133 | hour=0, 134 | minute=0, 135 | second=0, 136 | microsecond=0, 137 | weekday=rrule.SU(1) 138 | ) + timedelta(days=1) 139 | 140 | 141 | class TodayOnly(PayPeriod): 142 | @property 143 | def begin_period(self): 144 | return self.now - relativedelta.relativedelta( 145 | days=0, 146 | hour=0, 147 | minute=0, 148 | second=0, 149 | microsecond=0 150 | ) 151 | 152 | @property 153 | def end_period(self): 154 | return self.now + relativedelta.relativedelta( 155 | days=1, 156 | hour=0, 157 | minute=0, 158 | second=0, 159 | microsecond=0, 160 | ) 161 | 162 | 163 | class Quarterly(PayPeriod): 164 | def __init__(self, now): 165 | super(Quarterly, self).__init__(now) 166 | self.rule = rrule.rrule( 167 | rrule.MONTHLY, 168 | bymonth=(1, 4, 7, 10), 169 | bysetpos=-1, 170 | dtstart=datetime(self.now.year, 1, 1), 171 | count=8 172 | ) 173 | 174 | @property 175 | def begin_period(self): 176 | return self.rule.before(self.now) 177 | 178 | @property 179 | def end_period(self): 180 | return self.rule.after(self.now) 181 | -------------------------------------------------------------------------------- /timebook/payperiodutil.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2011-2012 Adam Coddington 2 | # 3 | # Permission is hereby granted, free of charge, to any person obtaining 4 | # a copy of this software and associated documentation files (the 5 | # "Software"), to deal in the Software without restriction, including 6 | # without limitation the rights to use, copy, modify, merge, publish, 7 | # distribute, sublicense, and/or sell copies of the Software, and to 8 | # permit persons to whom the Software is furnished to do so, subject to 9 | # the following conditions: 10 | # 11 | # The above copyright notice and this permission notice shall be 12 | # included in all copies or substantial portions of the Software. 13 | # 14 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 17 | # IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 18 | # CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 19 | # TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 20 | # SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | from datetime import datetime, timedelta 22 | import logging 23 | import time 24 | 25 | from timebook import payperiodtypes 26 | 27 | 28 | logger = logging.getLogger(__name__) 29 | 30 | 31 | class PayPeriodUtil(object): 32 | def __init__(self, db, payperiod_class): 33 | try: 34 | payperiod_cls = getattr( 35 | payperiodtypes, 36 | payperiod_class 37 | ) 38 | except AttributeError as e: 39 | logger.exception( 40 | "Payperiod type %s does not exist", 41 | payperiod_class 42 | ) 43 | raise e 44 | 45 | self.db = db 46 | self.now = datetime.now() 47 | self.payperiod = payperiod_cls(self.now) 48 | self.begin_period = self.payperiod.begin_period 49 | self.end_period = self.payperiod.end_period 50 | self.weekdays_rule = self.payperiod.weekdays_rule 51 | self.hours_per_day = self.payperiod.hours_per_day 52 | 53 | def get_hours_details(self): 54 | all_weekdays = self.weekdays_rule.between( 55 | # We need it to be able to count the beginning of the day 56 | # on `begin_period`; so subtract a microsecond. 57 | self.begin_period - timedelta(microseconds=1), 58 | self.now 59 | ) 60 | expected_hours = self.hours_per_day * len(all_weekdays) 61 | unpaid = 0 62 | vacation = 0 63 | holiday = 0 64 | 65 | for day in all_weekdays: 66 | if(self.is_holiday(day)): 67 | expected_hours = expected_hours - self.hours_per_day 68 | holiday = holiday + self.hours_per_day 69 | elif(self.is_unpaid(day)): 70 | expected_hours = expected_hours - self.hours_per_day 71 | unpaid = unpaid + self.hours_per_day 72 | elif(self.is_vacation(day)): 73 | expected_hours = expected_hours - self.hours_per_day 74 | vacation = vacation + self.hours_per_day 75 | 76 | total_hours = self.count_hours_after( 77 | self.begin_period, 78 | self.end_period 79 | ) 80 | adjustments = self.get_adjustments( 81 | self.begin_period, 82 | self.end_period, 83 | ) 84 | expected_hours -= adjustments 85 | 86 | out_time = datetime.now() + timedelta( 87 | hours=(expected_hours - total_hours) 88 | ) 89 | 90 | 91 | outgoing = { 92 | 'expected': expected_hours, 93 | 'actual': total_hours, 94 | 'vacation': vacation, 95 | 'unpaid': unpaid, 96 | 'holiday': holiday, 97 | 'adjustments': adjustments, 98 | 'out_time': out_time, 99 | 'begin_period': self.begin_period, 100 | 'end_period': self.end_period, 101 | } 102 | outgoing['balance'] = outgoing['actual'] - outgoing['expected'] 103 | return outgoing 104 | 105 | def get_adjustments(self, min_date, max_date): 106 | self.db.execute(""" 107 | SELECT SUM(adjustment) AS adjustment_sum 108 | FROM adjustments 109 | WHERE timestamp >= ? and timestamp < ? 110 | ; 111 | """, ( 112 | time.mktime(min_date.timetuple()), 113 | time.mktime(max_date.timetuple()), 114 | )) 115 | value = self.db.fetchone()[0] 116 | return value if value else 0 117 | 118 | def is_unpaid(self, date_to_check): 119 | dx = date_to_check 120 | self.db.execute(""" 121 | SELECT * FROM unpaid 122 | WHERE year = ? AND month = ? AND day = ? 123 | """, (dx.year, dx.month, dx.day,)) 124 | if(self.db.fetchone()): 125 | return True 126 | else: 127 | return False 128 | 129 | def is_vacation(self, date_to_check): 130 | dx = date_to_check 131 | self.db.execute(""" 132 | SELECT * FROM vacation 133 | WHERE year = ? AND month = ? AND day = ? 134 | """, (dx.year, dx.month, dx.day,)) 135 | if(self.db.fetchone()): 136 | return True 137 | else: 138 | return False 139 | 140 | def is_holiday(self, date_to_check): 141 | dx = date_to_check 142 | self.db.execute(""" 143 | SELECT * FROM holidays 144 | WHERE year = ? AND month = ? AND day = ? 145 | """, (dx.year, dx.month, dx.day,)) 146 | if(self.db.fetchone()): 147 | return True 148 | else: 149 | return False 150 | 151 | def count_hours_for_day(self, begin_time): 152 | self.db.execute(""" 153 | SELECT SUM( 154 | COALESCE(end_time, STRFTIME('%s', 'now')) 155 | - start_time) 156 | FROM entry 157 | WHERE 158 | start_time >= STRFTIME('%s', ?, 'utc') 159 | AND 160 | start_time <= STRFTIME('%s', ?, 'utc', '1 day') 161 | AND 162 | sheet = 'default' 163 | """, ( 164 | begin_time.strftime("%Y-%m-%d"), 165 | begin_time.strftime("%Y-%m-%d"), 166 | )) 167 | result = self.db.fetchone() 168 | if(result[0]): 169 | total_hours = float(result[0]) / 60 / 60 170 | else: 171 | total_hours = 0 172 | return total_hours 173 | 174 | def count_hours_after(self, begin_time, end_time): 175 | self.db.execute(""" 176 | SELECT SUM( 177 | COALESCE(end_time, STRFTIME('%s', 'now')) 178 | - start_time) 179 | FROM entry 180 | WHERE 181 | start_time >= STRFTIME('%s', ?, 'utc') 182 | AND 183 | ( 184 | end_time <= STRFTIME('%s', ?, 'utc', '1 day') 185 | OR 186 | end_time is null 187 | ) 188 | AND sheet = 'default' 189 | """, ( 190 | begin_time.strftime("%Y-%m-%d"), 191 | end_time.strftime("%Y-%m-%d") 192 | )) 193 | result = self.db.fetchone() 194 | if(result[0]): 195 | total_hours = float(result[0]) / 60 / 60 196 | else: 197 | total_hours = 0 198 | return total_hours 199 | --------------------------------------------------------------------------------