##// END OF EJS Templates
added revision into version for beta
marcink -
r807:e8c9ff1d beta
parent child Browse files
Show More
@@ -1,40 +1,48 b''
1 #!/usr/bin/env python
2 # encoding: utf-8
3 # RhodeCode, a web based repository management based on pylons
4 # Copyright (C) 2009-2010 Marcin Kuzminski <marcin@python-works.com>
5 #
1 # -*- coding: utf-8 -*-
2 """
3 package.rhodecode.__init__
4 ~~~~~~~~~~~~~~
5
6 RhodeCode, a web based repository management based on pylons
7 versioning implementation: http://semver.org/
8
9 :created_on: Apr 9, 2010
10 :author: marcink
11 :copyright: (C) 2009-2010 Marcin Kuzminski <marcin@python-works.com>
12 :license: GPLv3, see COPYING for more details.
13 """
6 14 # This program is free software; you can redistribute it and/or
7 15 # modify it under the terms of the GNU General Public License
8 16 # as published by the Free Software Foundation; version 2
9 17 # of the License or (at your opinion) any later version of the license.
10 18 #
11 19 # This program is distributed in the hope that it will be useful,
12 20 # but WITHOUT ANY WARRANTY; without even the implied warranty of
13 21 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 22 # GNU General Public License for more details.
15 23 #
16 24 # You should have received a copy of the GNU General Public License
17 25 # along with this program; if not, write to the Free Software
18 26 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston,
19 27 # MA 02110-1301, USA.
20 """
21 Created on April 9, 2010
22 RhodeCode, a web based repository management based on pylons
23 versioning implementation: http://semver.org/
24 @author: marcink
25 """
28
26 29
27 30 VERSION = (1, 1, 0, 'beta')
31 __version__ = '.'.join((str(each) for each in VERSION[:4]))
28 32
29 __version__ = '.'.join((str(each) for each in VERSION[:4]))
33
34 from rhodecode.lib.utils import get_current_revision
35 _rev = get_current_revision()
36 if 'beta' in VERSION and _rev:
37 __version__ += ' [rev:%s]' % _rev[0]
30 38
31 39 def get_version():
32 40 """
33 41 Returns shorter version (digit parts only) as string.
34 42 """
35 43 return '.'.join((str(each) for each in VERSION[:3]))
36 44
37 45 BACKENDS = {
38 46 'hg': 'Mercurial repository',
39 47 #'git': 'Git repository',
40 48 }
@@ -1,611 +1,628 b''
1 1 # -*- coding: utf-8 -*-
2 2 """
3 3 package.rhodecode.lib.utils
4 4 ~~~~~~~~~~~~~~
5 5
6 6 Utilities library for RhodeCode
7 7
8 8 :created_on: Apr 18, 2010
9 9 :author: marcink
10 10 :copyright: (C) 2009-2010 Marcin Kuzminski <marcin@python-works.com>
11 11 :license: GPLv3, see COPYING for more details.
12 12 """
13 13 # This program is free software; you can redistribute it and/or
14 14 # modify it under the terms of the GNU General Public License
15 15 # as published by the Free Software Foundation; version 2
16 16 # of the License or (at your opinion) any later version of the license.
17 17 #
18 18 # This program is distributed in the hope that it will be useful,
19 19 # but WITHOUT ANY WARRANTY; without even the implied warranty of
20 20 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
21 21 # GNU General Public License for more details.
22 22 #
23 23 # You should have received a copy of the GNU General Public License
24 24 # along with this program; if not, write to the Free Software
25 25 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston,
26 26 # MA 02110-1301, USA.
27 27
28 28 import os
29 29 import logging
30 30 import datetime
31 31 import traceback
32 import ConfigParser
33 32
34 33 from UserDict import DictMixin
35 34
36 35 from mercurial import ui, config, hg
37 36 from mercurial.error import RepoError
38 37
39 38 import paste
40 39 import beaker
41 40 from paste.script.command import Command, BadCommand
42 41
43 42 from vcs.backends.base import BaseChangeset
44 43 from vcs.utils.lazy import LazyProperty
45 44
46 45 from rhodecode.model import meta
47 46 from rhodecode.model.caching_query import FromCache
48 47 from rhodecode.model.db import Repository, User, RhodeCodeUi, UserLog
49 48 from rhodecode.model.repo import RepoModel
50 49 from rhodecode.model.user import UserModel
51 50
52 51 log = logging.getLogger(__name__)
53 52
54 53
55 54 def get_repo_slug(request):
56 55 return request.environ['pylons.routes_dict'].get('repo_name')
57 56
58 57 def action_logger(user, action, repo, ipaddr='', sa=None):
59 58 """
60 59 Action logger for various actions made by users
61 60
62 61 :param user: user that made this action, can be a unique username string or
63 62 object containing user_id attribute
64 63 :param action: action to log, should be on of predefined unique actions for
65 64 easy translations
66 65 :param repo: string name of repository or object containing repo_id,
67 66 that action was made on
68 67 :param ipaddr: optional ip address from what the action was made
69 68 :param sa: optional sqlalchemy session
70 69
71 70 """
72 71
73 72 if not sa:
74 73 sa = meta.Session()
75 74
76 75 try:
77 76 um = UserModel()
78 77 if hasattr(user, 'user_id'):
79 78 user_obj = user
80 79 elif isinstance(user, basestring):
81 80 user_obj = um.get_by_username(user, cache=False)
82 81 else:
83 82 raise Exception('You have to provide user object or username')
84 83
85 84
86 85 rm = RepoModel()
87 86 if hasattr(repo, 'repo_id'):
88 87 repo_obj = rm.get(repo.repo_id, cache=False)
89 88 repo_name = repo_obj.repo_name
90 89 elif isinstance(repo, basestring):
91 90 repo_name = repo.lstrip('/')
92 91 repo_obj = rm.get_by_repo_name(repo_name, cache=False)
93 92 else:
94 93 raise Exception('You have to provide repository to action logger')
95 94
96 95
97 96 user_log = UserLog()
98 97 user_log.user_id = user_obj.user_id
99 98 user_log.action = action
100 99
101 100 user_log.repository_id = repo_obj.repo_id
102 101 user_log.repository_name = repo_name
103 102
104 103 user_log.action_date = datetime.datetime.now()
105 104 user_log.user_ip = ipaddr
106 105 sa.add(user_log)
107 106 sa.commit()
108 107
109 108 log.info('Adding user %s, action %s on %s', user_obj, action, repo)
110 109 except:
111 110 log.error(traceback.format_exc())
112 111 sa.rollback()
113 112
114 113 def get_repos(path, recursive=False, initial=False):
115 114 """
116 115 Scans given path for repos and return (name,(type,path)) tuple
117 116 :param prefix:
118 117 :param path:
119 118 :param recursive:
120 119 :param initial:
121 120 """
122 121 from vcs.utils.helpers import get_scm
123 122 from vcs.exceptions import VCSError
124 123
125 124 try:
126 125 scm = get_scm(path)
127 126 except:
128 127 pass
129 128 else:
130 129 raise Exception('The given path %s should not be a repository got %s',
131 130 path, scm)
132 131
133 132 for dirpath in os.listdir(path):
134 133 try:
135 134 yield dirpath, get_scm(os.path.join(path, dirpath))
136 135 except VCSError:
137 136 pass
138 137
139 138 def check_repo_fast(repo_name, base_path):
140 139 """
141 140 Check given path for existance of directory
142 141 :param repo_name:
143 142 :param base_path:
144 143
145 144 :return False: if this directory is present
146 145 """
147 146 if os.path.isdir(os.path.join(base_path, repo_name)):return False
148 147 return True
149 148
150 149 def check_repo(repo_name, base_path, verify=True):
151 150
152 151 repo_path = os.path.join(base_path, repo_name)
153 152
154 153 try:
155 154 if not check_repo_fast(repo_name, base_path):
156 155 return False
157 156 r = hg.repository(ui.ui(), repo_path)
158 157 if verify:
159 158 hg.verify(r)
160 159 #here we hnow that repo exists it was verified
161 160 log.info('%s repo is already created', repo_name)
162 161 return False
163 162 except RepoError:
164 163 #it means that there is no valid repo there...
165 164 log.info('%s repo is free for creation', repo_name)
166 165 return True
167 166
168 167 def ask_ok(prompt, retries=4, complaint='Yes or no, please!'):
169 168 while True:
170 169 ok = raw_input(prompt)
171 170 if ok in ('y', 'ye', 'yes'): return True
172 171 if ok in ('n', 'no', 'nop', 'nope'): return False
173 172 retries = retries - 1
174 173 if retries < 0: raise IOError
175 174 print complaint
176 175
177 176 #propagated from mercurial documentation
178 177 ui_sections = ['alias', 'auth',
179 178 'decode/encode', 'defaults',
180 179 'diff', 'email',
181 180 'extensions', 'format',
182 181 'merge-patterns', 'merge-tools',
183 182 'hooks', 'http_proxy',
184 183 'smtp', 'patch',
185 184 'paths', 'profiling',
186 185 'server', 'trusted',
187 186 'ui', 'web', ]
188 187
189 188 def make_ui(read_from='file', path=None, checkpaths=True):
190 189 """
191 190 A function that will read python rc files or database
192 191 and make an mercurial ui object from read options
193 192
194 193 :param path: path to mercurial config file
195 194 :param checkpaths: check the path
196 195 :param read_from: read from 'file' or 'db'
197 196 """
198 197
199 198 baseui = ui.ui()
200 199
201 200 #clean the baseui object
202 201 baseui._ocfg = config.config()
203 202 baseui._ucfg = config.config()
204 203 baseui._tcfg = config.config()
205 204
206 205 if read_from == 'file':
207 206 if not os.path.isfile(path):
208 207 log.warning('Unable to read config file %s' % path)
209 208 return False
210 209 log.debug('reading hgrc from %s', path)
211 210 cfg = config.config()
212 211 cfg.read(path)
213 212 for section in ui_sections:
214 213 for k, v in cfg.items(section):
215 214 log.debug('settings ui from file[%s]%s:%s', section, k, v)
216 215 baseui.setconfig(section, k, v)
217 216
218 217
219 218 elif read_from == 'db':
220 219 sa = meta.Session()
221 220 ret = sa.query(RhodeCodeUi)\
222 221 .options(FromCache("sql_cache_short",
223 222 "get_hg_ui_settings")).all()
224 223
225 224 hg_ui = ret
226 225 for ui_ in hg_ui:
227 226 if ui_.ui_active:
228 227 log.debug('settings ui from db[%s]%s:%s', ui_.ui_section,
229 228 ui_.ui_key, ui_.ui_value)
230 229 baseui.setconfig(ui_.ui_section, ui_.ui_key, ui_.ui_value)
231 230
232 231 meta.Session.remove()
233 232 return baseui
234 233
235 234
236 235 def set_rhodecode_config(config):
237 236 """
238 237 Updates pylons config with new settings from database
239 238 :param config:
240 239 """
241 240 from rhodecode.model.settings import SettingsModel
242 241 hgsettings = SettingsModel().get_app_settings()
243 242
244 243 for k, v in hgsettings.items():
245 244 config[k] = v
246 245
247 246 def invalidate_cache(cache_key, *args):
248 247 """
249 248 Puts cache invalidation task into db for
250 249 further global cache invalidation
251 250 """
252 251 from rhodecode.model.scm import ScmModel
253 252
254 253 if cache_key.startswith('get_repo_cached_'):
255 254 name = cache_key.split('get_repo_cached_')[-1]
256 255 ScmModel().mark_for_invalidation(name)
257 256
258 257 class EmptyChangeset(BaseChangeset):
259 258 """
260 259 An dummy empty changeset. It's possible to pass hash when creating
261 260 an EmptyChangeset
262 261 """
263 262
264 263 def __init__(self, cs='0' * 40):
265 264 self._empty_cs = cs
266 265 self.revision = -1
267 266 self.message = ''
268 267 self.author = ''
269 268 self.date = ''
270 269
271 270 @LazyProperty
272 271 def raw_id(self):
273 272 """
274 273 Returns raw string identifying this changeset, useful for web
275 274 representation.
276 275 """
277 276 return self._empty_cs
278 277
279 278 @LazyProperty
280 279 def short_id(self):
281 280 return self.raw_id[:12]
282 281
283 282 def get_file_changeset(self, path):
284 283 return self
285 284
286 285 def get_file_content(self, path):
287 286 return u''
288 287
289 288 def get_file_size(self, path):
290 289 return 0
291 290
292 291 def repo2db_mapper(initial_repo_list, remove_obsolete=False):
293 292 """
294 293 maps all found repositories into db
295 294 """
296 295
297 296 sa = meta.Session()
298 297 rm = RepoModel()
299 298 user = sa.query(User).filter(User.admin == True).first()
300 299
301 300 for name, repo in initial_repo_list.items():
302 301 if not rm.get_by_repo_name(name, cache=False):
303 302 log.info('repository %s not found creating default', name)
304 303
305 304 form_data = {
306 305 'repo_name':name,
307 306 'repo_type':repo.alias,
308 307 'description':repo.description \
309 308 if repo.description != 'unknown' else \
310 309 '%s repository' % name,
311 310 'private':False
312 311 }
313 312 rm.create(form_data, user, just_db=True)
314 313
315 314 if remove_obsolete:
316 315 #remove from database those repositories that are not in the filesystem
317 316 for repo in sa.query(Repository).all():
318 317 if repo.repo_name not in initial_repo_list.keys():
319 318 sa.delete(repo)
320 319 sa.commit()
321 320
322 321 class OrderedDict(dict, DictMixin):
323 322
324 323 def __init__(self, *args, **kwds):
325 324 if len(args) > 1:
326 325 raise TypeError('expected at most 1 arguments, got %d' % len(args))
327 326 try:
328 327 self.__end
329 328 except AttributeError:
330 329 self.clear()
331 330 self.update(*args, **kwds)
332 331
333 332 def clear(self):
334 333 self.__end = end = []
335 334 end += [None, end, end] # sentinel node for doubly linked list
336 335 self.__map = {} # key --> [key, prev, next]
337 336 dict.clear(self)
338 337
339 338 def __setitem__(self, key, value):
340 339 if key not in self:
341 340 end = self.__end
342 341 curr = end[1]
343 342 curr[2] = end[1] = self.__map[key] = [key, curr, end]
344 343 dict.__setitem__(self, key, value)
345 344
346 345 def __delitem__(self, key):
347 346 dict.__delitem__(self, key)
348 347 key, prev, next = self.__map.pop(key)
349 348 prev[2] = next
350 349 next[1] = prev
351 350
352 351 def __iter__(self):
353 352 end = self.__end
354 353 curr = end[2]
355 354 while curr is not end:
356 355 yield curr[0]
357 356 curr = curr[2]
358 357
359 358 def __reversed__(self):
360 359 end = self.__end
361 360 curr = end[1]
362 361 while curr is not end:
363 362 yield curr[0]
364 363 curr = curr[1]
365 364
366 365 def popitem(self, last=True):
367 366 if not self:
368 367 raise KeyError('dictionary is empty')
369 368 if last:
370 369 key = reversed(self).next()
371 370 else:
372 371 key = iter(self).next()
373 372 value = self.pop(key)
374 373 return key, value
375 374
376 375 def __reduce__(self):
377 376 items = [[k, self[k]] for k in self]
378 377 tmp = self.__map, self.__end
379 378 del self.__map, self.__end
380 379 inst_dict = vars(self).copy()
381 380 self.__map, self.__end = tmp
382 381 if inst_dict:
383 382 return (self.__class__, (items,), inst_dict)
384 383 return self.__class__, (items,)
385 384
386 385 def keys(self):
387 386 return list(self)
388 387
389 388 setdefault = DictMixin.setdefault
390 389 update = DictMixin.update
391 390 pop = DictMixin.pop
392 391 values = DictMixin.values
393 392 items = DictMixin.items
394 393 iterkeys = DictMixin.iterkeys
395 394 itervalues = DictMixin.itervalues
396 395 iteritems = DictMixin.iteritems
397 396
398 397 def __repr__(self):
399 398 if not self:
400 399 return '%s()' % (self.__class__.__name__,)
401 400 return '%s(%r)' % (self.__class__.__name__, self.items())
402 401
403 402 def copy(self):
404 403 return self.__class__(self)
405 404
406 405 @classmethod
407 406 def fromkeys(cls, iterable, value=None):
408 407 d = cls()
409 408 for key in iterable:
410 409 d[key] = value
411 410 return d
412 411
413 412 def __eq__(self, other):
414 413 if isinstance(other, OrderedDict):
415 414 return len(self) == len(other) and self.items() == other.items()
416 415 return dict.__eq__(self, other)
417 416
418 417 def __ne__(self, other):
419 418 return not self == other
420 419
421 420
422 421 #set cache regions for beaker so celery can utilise it
423 422 def add_cache(settings):
424 423 cache_settings = {'regions':None}
425 424 for key in settings.keys():
426 425 for prefix in ['beaker.cache.', 'cache.']:
427 426 if key.startswith(prefix):
428 427 name = key.split(prefix)[1].strip()
429 428 cache_settings[name] = settings[key].strip()
430 429 if cache_settings['regions']:
431 430 for region in cache_settings['regions'].split(','):
432 431 region = region.strip()
433 432 region_settings = {}
434 433 for key, value in cache_settings.items():
435 434 if key.startswith(region):
436 435 region_settings[key.split('.')[1]] = value
437 436 region_settings['expire'] = int(region_settings.get('expire',
438 437 60))
439 438 region_settings.setdefault('lock_dir',
440 439 cache_settings.get('lock_dir'))
441 440 if 'type' not in region_settings:
442 441 region_settings['type'] = cache_settings.get('type',
443 442 'memory')
444 443 beaker.cache.cache_regions[region] = region_settings
445 444
445 def get_current_revision():
446 """
447 Returns tuple of (number, id) from repository containing this package
448 or None if repository could not be found.
449 """
450 try:
451 from vcs import get_repo
452 from vcs.utils.helpers import get_scm
453 from vcs.exceptions import RepositoryError, VCSError
454 repopath = os.path.join(os.path.dirname(__file__), '..', '..')
455 scm = get_scm(repopath)[0]
456 repo = get_repo(path=repopath, alias=scm)
457 tip = repo.get_changeset()
458 return (tip.revision, tip.short_id)
459 except (ImportError, RepositoryError, VCSError), err:
460 logging.debug("Cannot retrieve rhodecode's revision. Original error "
461 "was: %s" % err)
462 return None
446 463
447 464 #===============================================================================
448 465 # TEST FUNCTIONS AND CREATORS
449 466 #===============================================================================
450 467 def create_test_index(repo_location, full_index):
451 468 """Makes default test index
452 469 :param repo_location:
453 470 :param full_index:
454 471 """
455 472 from rhodecode.lib.indexers.daemon import WhooshIndexingDaemon
456 473 from rhodecode.lib.pidlock import DaemonLock, LockHeld
457 474 import shutil
458 475
459 476 index_location = os.path.join(repo_location, 'index')
460 477 if os.path.exists(index_location):
461 478 shutil.rmtree(index_location)
462 479
463 480 try:
464 481 l = DaemonLock()
465 482 WhooshIndexingDaemon(index_location=index_location,
466 483 repo_location=repo_location)\
467 484 .run(full_index=full_index)
468 485 l.release()
469 486 except LockHeld:
470 487 pass
471 488
472 489 def create_test_env(repos_test_path, config):
473 490 """Makes a fresh database and
474 491 install test repository into tmp dir
475 492 """
476 493 from rhodecode.lib.db_manage import DbManage
477 494 from rhodecode.tests import HG_REPO, GIT_REPO, NEW_HG_REPO, NEW_GIT_REPO, \
478 495 HG_FORK, GIT_FORK, TESTS_TMP_PATH
479 496 import tarfile
480 497 import shutil
481 498 from os.path import dirname as dn, join as jn, abspath
482 499
483 500 log = logging.getLogger('TestEnvCreator')
484 501 # create logger
485 502 log.setLevel(logging.DEBUG)
486 503 log.propagate = True
487 504 # create console handler and set level to debug
488 505 ch = logging.StreamHandler()
489 506 ch.setLevel(logging.DEBUG)
490 507
491 508 # create formatter
492 509 formatter = logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s")
493 510
494 511 # add formatter to ch
495 512 ch.setFormatter(formatter)
496 513
497 514 # add ch to logger
498 515 log.addHandler(ch)
499 516
500 517 #PART ONE create db
501 518 dbconf = config['sqlalchemy.db1.url']
502 519 log.debug('making test db %s', dbconf)
503 520
504 521 dbmanage = DbManage(log_sql=True, dbconf=dbconf, root=config['here'],
505 522 tests=True)
506 523 dbmanage.create_tables(override=True)
507 524 dbmanage.config_prompt(repos_test_path)
508 525 dbmanage.create_default_user()
509 526 dbmanage.admin_prompt()
510 527 dbmanage.create_permissions()
511 528 dbmanage.populate_default_permissions()
512 529
513 530 #PART TWO make test repo
514 531 log.debug('making test vcs repositories')
515 532
516 533 #remove old one from previos tests
517 534 for r in [HG_REPO, GIT_REPO, NEW_HG_REPO, NEW_GIT_REPO, HG_FORK, GIT_FORK]:
518 535
519 536 if os.path.isdir(jn(TESTS_TMP_PATH, r)):
520 537 log.debug('removing %s', r)
521 538 shutil.rmtree(jn(TESTS_TMP_PATH, r))
522 539
523 540 #CREATE DEFAULT HG REPOSITORY
524 541 cur_dir = dn(dn(abspath(__file__)))
525 542 tar = tarfile.open(jn(cur_dir, 'tests', "vcs_test_hg.tar.gz"))
526 543 tar.extractall(jn(TESTS_TMP_PATH, HG_REPO))
527 544 tar.close()
528 545
529 546
530 547 #==============================================================================
531 548 # PASTER COMMANDS
532 549 #==============================================================================
533 550
534 551 class BasePasterCommand(Command):
535 552 """
536 553 Abstract Base Class for paster commands.
537 554
538 555 The celery commands are somewhat aggressive about loading
539 556 celery.conf, and since our module sets the `CELERY_LOADER`
540 557 environment variable to our loader, we have to bootstrap a bit and
541 558 make sure we've had a chance to load the pylons config off of the
542 559 command line, otherwise everything fails.
543 560 """
544 561 min_args = 1
545 562 min_args_error = "Please provide a paster config file as an argument."
546 563 takes_config_file = 1
547 564 requires_config_file = True
548 565
549 566 def run(self, args):
550 567 """
551 568 Overrides Command.run
552 569
553 570 Checks for a config file argument and loads it.
554 571 """
555 572 if len(args) < self.min_args:
556 573 raise BadCommand(
557 574 self.min_args_error % {'min_args': self.min_args,
558 575 'actual_args': len(args)})
559 576
560 577 # Decrement because we're going to lob off the first argument.
561 578 # @@ This is hacky
562 579 self.min_args -= 1
563 580 self.bootstrap_config(args[0])
564 581 self.update_parser()
565 582 return super(BasePasterCommand, self).run(args[1:])
566 583
567 584 def update_parser(self):
568 585 """
569 586 Abstract method. Allows for the class's parser to be updated
570 587 before the superclass's `run` method is called. Necessary to
571 588 allow options/arguments to be passed through to the underlying
572 589 celery command.
573 590 """
574 591 raise NotImplementedError("Abstract Method.")
575 592
576 593 def bootstrap_config(self, conf):
577 594 """
578 595 Loads the pylons configuration.
579 596 """
580 597 from pylons import config as pylonsconfig
581 598
582 599 path_to_ini_file = os.path.realpath(conf)
583 600 conf = paste.deploy.appconfig('config:' + path_to_ini_file)
584 601 pylonsconfig.init_app(conf.global_conf, conf.local_conf)
585 602
586 603
587 604
588 605 class UpgradeDb(BasePasterCommand):
589 606 """Command used for paster to upgrade our database to newer version
590 607 """
591 608
592 609 max_args = 1
593 610 min_args = 1
594 611
595 612 usage = "CONFIG_FILE"
596 613 summary = "Upgrades current db to newer version given configuration file"
597 614 group_name = "RhodeCode"
598 615
599 616 parser = Command.standard_parser(verbose=True)
600 617
601 618 def command(self):
602 619 from pylons import config
603 620 raise NotImplementedError('Not implemented yet')
604 621
605 622
606 623 def update_parser(self):
607 624 self.parser.add_option('--sql',
608 625 action='store_true',
609 626 dest='just_sql',
610 627 help="Prints upgrade sql for further investigation",
611 628 default=False)
General Comments 0
You need to be logged in to leave comments. Login now